Vercel Logo

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

  1. Create server routes for fetching and submitting reviews on a spring
  2. Add a review form to the detail page for authenticated users
  3. Display the review list below the spring details

Hands-on exercise 4.3

Build the reviews feature end to end.

Requirements:

  1. Create server/api/springs/[id]/reviews.get.ts that aggregates reviews from all users for a given spring
  2. Create server/api/springs/[id]/reviews.post.ts that adds a review for the authenticated user
  3. Add a review form to the detail page (textarea + submit button) for logged-in users
  4. Display existing reviews below the spring details with author and date
  5. 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.prevent on a <form> is Vue's e.preventDefault(). It stops the page from reloading
  • refresh from useFetch re-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:

server/api/springs/[id]/reviews.get.ts
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):

server/api/springs/[id]/reviews.post.ts
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:

app/pages/springs/[id].vue: review script additions
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:

app/pages/springs/[id].vue: review 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.

Event modifiers save boilerplate

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.

Reviews are public, writes are private

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

  1. Log in and visit /springs/breitenbush-hot-springs
  2. Scroll to the Reviews section. You should see "No reviews yet"
  3. 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."
  4. Click "Submit Review." The review should appear immediately with your GitHub username and today's date
  5. 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/reviews returns 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

server/api/springs/[id]/reviews.get.ts
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()
  );
});
server/api/springs/[id]/reviews.post.ts
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?

supported.