---
title: "Reviews"
description: "Build a review system with server routes for submitting and fetching reviews, a review form on the detail page, and a review list that aggregates across users."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/reviews"
md_url: "https://vercel.com/academy/nuxt-on-vercel/reviews.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-02T21:49:17.324Z"
content_type: "lesson"
course: "nuxt-on-vercel"
course_title: "Nuxt on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Reviews

# 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

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:

```typescript title="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()
  );
});
```

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):

```typescript title="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:

```typescript title="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();
}
```

`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:

```tsx title="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:

```tsx
// 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.

\*\*Note: 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.

\*\*Warning: 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

```bash
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

```typescript title="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()
  );
});
```

```typescript title="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 };
});
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
