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.
The course's first Vue form introduces v-model for two-way binding and the @submit.prevent event modifier. The React comparison uses a controlled input, onChange, and e.preventDefault().
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()
);
});A database-backed version would query a reviews table by spring ID. The JSON implementation still demonstrates aggregation across users and sorting by date.
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();
}The refresh function returned by useFetch runs the request again. After submission, clear the textarea and refresh the review list. This corresponds to calling mutate() in SWR or invalidateQueries() in React Query.
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>
);In the Vue version, v-model manages the input and @submit.prevent handles the event modifier. Nuxt's $fetch also sets the JSON Content-Type header for the request body.
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?