Reviews
Favorites and visited tracking are private. Reviews are public. This changes the data model: instead of one user reading their own data, we need to aggregate reviews from all users for a given spring. The write path is similar to favorites, but the read path crosses user boundaries.
This is also the first time we'll use a form in Vue. In React, a form like this means controlled inputs, onChange handlers, e.preventDefault(), and maybe a library like React Hook Form if you want validation. Vue replaces all of that with two features: v-model for two-way binding and @submit.prevent as a built-in event modifier. No library, no boilerplate.
Outcome
Build a review system where authenticated users can submit reviews and anyone can read them on spring detail pages.
Fast Track
- Create server routes for fetching and submitting reviews on a spring
- Add a review form to the detail page for authenticated users
- Display the review list below the spring details
Hands-on exercise 4.3
Build the reviews feature end to end.
Requirements:
- Create
server/api/springs/[id]/reviews.get.tsthat aggregates reviews from all users for a given spring - Create
server/api/springs/[id]/reviews.post.tsthat adds a review for the authenticated user - Add a review form to the detail page (textarea + submit button) for logged-in users
- Display existing reviews below the spring details with author and date
- After submitting a review, refresh the review list without a full page reload
Implementation hints:
- The GET route needs to read ALL user data files and collect reviews matching the spring ID
- The POST route stores the review in the current user's data file
@submit.preventon a<form>is Vue'se.preventDefault(). It stops the page from reloadingrefreshfromuseFetchre-runs the fetch without resetting the component. Use it after submitting a review
The reviews GET route is the most complex server route in the app. It reads every user's data file and collects reviews for the requested spring:
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
const DATA_DIR = join(process.cwd(), ".data", "users");
export default defineEventHandler(async (event) => {
const springId = getRouterParam(event, "id");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
if (!existsSync(DATA_DIR)) {
return [];
}
const files = await readdir(DATA_DIR);
const allReviews: {
id: string;
springId: string;
body: string;
author: string;
createdAt: string;
}[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
const raw = await readFile(join(DATA_DIR, file), "utf-8");
const userData = JSON.parse(raw);
const springReviews = (userData.reviews || [])
.filter((r: { springId: string }) => r.springId === springId)
.map((r: { id: string; body: string; createdAt: string }) => ({
...r,
author: userData.login || `User ${file.replace(".json", "")}`,
}));
allReviews.push(...springReviews);
}
return allReviews.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
});This isn't how you'd build it with a database. With Postgres, you'd query the reviews table by spring ID. But the pattern of aggregating across users and sorting by date is the same regardless of storage.
The POST route stores the review and the user's login name (so we can attribute it later):
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "id");
const body = await readBody<{ body: string }>(event);
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
if (!body?.body?.trim()) {
throw createError({
statusCode: 400,
statusMessage: "Review body is required",
});
}
const data = await getUserData(session.user.id);
(data as any).login = session.user.login;
data.reviews.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
springId,
body: body.body.trim(),
createdAt: new Date().toISOString(),
});
await setUserData(session.user.id, data);
return { success: true };
});readBody parses the request body. It's the Nitro equivalent of await request.json() in a Next.js Route Handler. The generic <{ body: string }> gives you type safety on the parsed result. In Next.js, you'd write const { body } = await request.json() and handle the typing yourself.
Now the detail page additions. First, fetch reviews and set up the form state:
const { data: reviews, refresh: refreshReviews } = await useFetch(
`/api/springs/${route.params.id}/reviews`,
{ default: () => [] }
);
const reviewBody = ref("");
async function submitReview() {
if (!spring.value || !reviewBody.value.trim()) return;
await $fetch(`/api/springs/${spring.value.id}/reviews`, {
method: "POST",
body: { body: reviewBody.value },
});
reviewBody.value = "";
await refreshReviews();
}refresh is a function returned by useFetch that re-runs the fetch. After submitting a review, we clear the textarea and refresh the review list. The new review appears without a page reload. This is Nuxt's built-in equivalent of calling mutate() in SWR or invalidateQueries() in React Query. No cache key management required.
The form and review list in the template:
<div>
<h2>Reviews</h2>
<form v-if="loggedIn" @submit.prevent="submitReview">
<textarea v-model="reviewBody" rows="3" placeholder="Share your experience at this spring..." />
<button type="submit" :disabled="!reviewBody.trim()">
Submit Review
</button>
</form>
<div v-if="reviews?.length">
<div v-for="review in reviews" :key="review.id">
<div>
<span>
{{ review.author }}
</span>
<span>
{{ new Date(review.createdAt).toLocaleDateString() }}
</span>
</div>
<p>{{ review.body }}</p>
</div>
</div>
<p v-else>
No reviews yet. Be the first to share your experience.
</p>
</div>@submit.prevent is the Vue shorthand for event.preventDefault() on form submission. In React, you'd write onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}. Vue rolls the modifier into the directive.
:disabled="!reviewBody.trim()" disables the button when the textarea is empty. Because reviewBody is a ref bound with v-model, the disabled state updates on every keystroke. No onChange handler needed.
Compare the full form flow in React:
// React version — for comparison only
const [reviewBody, setReviewBody] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!reviewBody.trim()) return;
await fetch(`/api/springs/${spring.id}/reviews`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: reviewBody }),
});
setReviewBody("");
mutate(); // SWR or React Query invalidation
}
return (
<form onSubmit={handleSubmit}>
<textarea
value={reviewBody}
onChange={(e) => setReviewBody(e.target.value)}
/>
<button disabled={!reviewBody.trim()}>Submit</button>
</form>
);The Vue version eliminates useState, the onChange handler, e.preventDefault(), and the Content-Type header (Nuxt's $fetch sets it automatically). v-model replaces the controlled input pattern entirely, and @submit.prevent is a one-word replacement for the manual preventDefault call.
Vue has modifiers for common event patterns: .prevent (preventDefault), .stop (stopPropagation), .once (fire once), .enter (only on Enter key). You can chain them: @keydown.enter.prevent="submit". In React, you'd handle all of these manually in the event handler.
The GET route reads all user files (public data). The POST route uses requireUserSession (authenticated only). Make sure you don't accidentally expose other user data in the GET route. We only return the review body, author name, and timestamp.
Try It
- Log in and visit
/springs/breitenbush-hot-springs - Scroll to the Reviews section. You should see "No reviews yet"
- Type a review in the textarea: "The river setting is unreal. Went in January and the contrast between the cold air and hot water was perfect."
- Click "Submit Review." The review should appear immediately with your GitHub username and today's date
- Open an incognito window (logged out) and visit the same spring. The review should be visible but the form should not appear
Commit
git add -A && git commit -m "feat(reviews): add review submission and display on detail pages"Done-When
- GET
/api/springs/breitenbush-hot-springs/reviewsreturns reviews from all users - POST adds a review for the authenticated user
- The review form appears only for logged-in users
- Submitted reviews appear immediately without a page reload
- Reviews show the author's GitHub username and date
Solution
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
const DATA_DIR = join(process.cwd(), ".data", "users");
export default defineEventHandler(async (event) => {
const springId = getRouterParam(event, "id");
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
if (!existsSync(DATA_DIR)) {
return [];
}
const files = await readdir(DATA_DIR);
const allReviews: {
id: string;
springId: string;
body: string;
author: string;
createdAt: string;
}[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
const raw = await readFile(join(DATA_DIR, file), "utf-8");
const userData = JSON.parse(raw);
const springReviews = (userData.reviews || [])
.filter((r: { springId: string }) => r.springId === springId)
.map((r: { id: string; body: string; createdAt: string }) => ({
...r,
author: userData.login || `User ${file.replace(".json", "")}`,
}));
allReviews.push(...springReviews);
}
return allReviews.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
});export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const springId = getRouterParam(event, "id");
const body = await readBody<{ body: string }>(event);
if (!springId) {
throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
}
if (!body?.body?.trim()) {
throw createError({
statusCode: 400,
statusMessage: "Review body is required",
});
}
const data = await getUserData(session.user.id);
(data as any).login = session.user.login;
data.reviews.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
springId,
body: body.body.trim(),
createdAt: new Date().toISOString(),
});
await setUserData(session.user.id, data);
return { success: true };
});Was this helpful?