---
title: "Dynamic Routes"
description: "Create a dynamic detail page using route parameters, fetch individual spring data with useFetch, and handle 404 errors for missing springs."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/dynamic-routes"
md_url: "https://vercel.com/academy/nuxt-on-vercel/dynamic-routes.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-03T15:52:27.553Z"
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>

# Dynamic Routes

# Dynamic Routes

A browse page that lists every hot spring is useful. But the real test of any data-driven app is the detail page: one URL, one record, and a 404 if it doesn't exist. This is where you learn how a framework handles the gap between "I know this thing exists" and "prove it."

The detail page needs to pull an ID from the URL, fetch a single record, and fail gracefully when someone types nonsense into the address bar. In Next.js, that's `params` plus a Server Component. In Nuxt, it's `useRoute()` plus `useFetch` plus `createError`. Three tools, one page.

## Outcome

Build a detail page that displays full information for a single hot spring.

## Fast Track

1. Create the server route `server/api/springs/[id].get.ts` that returns a single spring
2. Update `app/pages/springs/[id].vue` to fetch and display the spring
3. Handle the 404 case when a spring doesn't exist

## Hands-on exercise 2.3

Build both the server route and the page for viewing a single hot spring.

**Requirements:**

1. Create `server/api/springs/[id].get.ts` that finds a spring by ID and returns it
2. Throw a 404 error if the spring doesn't exist
3. Update `app/pages/springs/[id].vue` to fetch the spring and display its full details
4. Show location, temperature, elevation, and features
5. Include a back link to the browse page

**Implementation hints:**

- `getRouterParam(event, "id")` extracts route params in server routes. It's the server equivalent of `useRoute().params.id`
- `createError({ statusCode: 404 })` throws an error that Nuxt catches and renders as an error page
- `await useFetch(...)` with `await` blocks page rendering until the data loads. Without `await`, the page renders immediately with null data

Let's start with the server route. We need to find a single spring by its ID:

```typescript title="server/api/springs/[id].get.ts"
import type { Spring } from "~/types/spring";
import springs from "~/server/data/springs.json";

export default defineEventHandler((event) => {
  const id = getRouterParam(event, "id");
  const spring = (springs as Spring[]).find((s) => s.id === id);

  if (!spring) {
    throw createError({
      statusCode: 404,
      statusMessage: "Spring not found",
    });
  }

  return spring;
});
```

`getRouterParam` pulls the `id` from the URL. If someone visits `/api/springs/breitenbush-hot-springs`, `id` is `"breitenbush-hot-springs"`. If the spring doesn't exist, we throw a 404. In Next.js, you'd return `NextResponse.json({ error: "Not found" }, { status: 404 })`. Nuxt's `createError` is more concise and integrates with the error page system.

Now the page. This is the biggest Vue template we've written so far:

```vue title="app/pages/springs/[id].vue"
<script setup lang="ts">
const route = useRoute();

const { data: spring, error } = await useFetch(
  `/api/springs/${route.params.id}`
);

if (error.value) {
  throw createError({
    statusCode: 404,
    statusMessage: "Spring not found",
  });
}
</script>
```

Notice the `await` before `useFetch`. This is important. With `await`, the page waits for data before rendering. Without it, `spring` starts as `null` and you'd need to handle that in the template. For a detail page where the whole content depends on the data, `await` is the right call.

The `if (error.value)` check catches both network errors and our 404 from the server route. `createError` on the client side triggers Nuxt's error page, which shows a full-page error with the status code and message.

The template renders the spring's details in a structured layout:

```vue title="app/pages/springs/[id].vue"
<template>
  <div v-if="spring">
    <NuxtLink to="/springs">
      &larr; Back to all springs
    </NuxtLink>

    <div>
      <div>
        <h1>
          {{ spring.name }}
        </h1>
        <span>
          {{ spring.type }}
        </span>
      </div>
      <p>
        {{ spring.description }}
      </p>
    </div>

    <div>
      <div>
        <p>
          Location
        </p>
        <p>
          {{ spring.location.region }}, {{ spring.location.country }}
        </p>
      </div>
      <div>
        <p>
          Temperature
        </p>
        <p>
          {{ spring.temperature.min }}–{{ spring.temperature.max }}°F
        </p>
      </div>
      <div>
        <p>
          Elevation
        </p>
        <p>
          {{ spring.elevation.toLocaleString() }} ft
        </p>
      </div>
    </div>

    <div>
      <h2>
        Features
      </h2>
      <div>
        <span v-for="feature in spring.features" :key="feature">
          {{ feature }}
        </span>
      </div>
    </div>
  </div>
</template>
```

The `v-if="spring"` guard at the top is a safety net. Even though `await useFetch` should guarantee the data exists, TypeScript's type narrowing doesn't know that. The `v-if` satisfies both TypeScript and the edge case where someone navigates directly to a broken URL.

\*\*Note: await changes navigation behavior\*\*

Using `await useFetch(...)` in a page means Nuxt waits for the data before transitioning to the page. The user stays on the previous page until the fetch completes. If you want the page to render immediately with a loading state, drop the `await` and check `status` in the template, like we did on the browse page.

\*\*Warning: Route params are strings\*\*

`route.params.id` is always a string, even if it looks like a number. If your IDs were numeric, you'd need to parse them. Our spring IDs are slugs, so this isn't an issue, but it catches people who switch from numeric to slug-based routes.

## Try It

Visit `http://localhost:3000/springs/breitenbush-hot-springs`. You should see:

1. A back link to the browse page
2. The spring name ("Breitenbush Hot Springs") with a "developed" badge
3. The full description
4. A three-column grid showing location, temperature range, and elevation
5. Feature tags (clothing-optional, forest-setting, riverside, etc.)

Click the back link. You should return to the browse page with all springs loaded.

Now try a URL that doesn't exist: `http://localhost:3000/springs/nonexistent-spring`. You should see Nuxt's error page with a 404 status.

## Commit

```bash
git add -A && git commit -m "feat(detail): add single spring API route and detail page"
```

## Done-When

- [ ] `/api/springs/breitenbush-hot-springs` returns a single spring as JSON
- [ ] `/api/springs/nonexistent-spring` returns a 404 error
- [ ] The detail page displays full spring information including features
- [ ] The back link navigates to the browse page without a full page reload

## Solution

```typescript title="server/api/springs/[id].get.ts"
import type { Spring } from "~/types/spring";
import springs from "~/server/data/springs.json";

export default defineEventHandler((event) => {
  const id = getRouterParam(event, "id");
  const spring = (springs as Spring[]).find((s) => s.id === id);

  if (!spring) {
    throw createError({
      statusCode: 404,
      statusMessage: "Spring not found",
    });
  }

  return spring;
});
```

```vue title="app/pages/springs/[id].vue"
<script setup lang="ts">
const route = useRoute();

const { data: spring, error } = await useFetch(
  `/api/springs/${route.params.id}`
);

if (error.value) {
  throw createError({
    statusCode: 404,
    statusMessage: "Spring not found",
  });
}
</script>

<template>
  <div v-if="spring">
    <NuxtLink to="/springs">
      &larr; Back to all springs
    </NuxtLink>

    <div>
      <div>
        <h1>
          {{ spring.name }}
        </h1>
        <span>
          {{ spring.type }}
        </span>
      </div>
      <p>
        {{ spring.description }}
      </p>
    </div>

    <div>
      <div>
        <p>Location</p>
        <p>
          {{ spring.location.region }}, {{ spring.location.country }}
        </p>
      </div>
      <div>
        <p>Temperature</p>
        <p>
          {{ spring.temperature.min }}–{{ spring.temperature.max }}°F
        </p>
      </div>
      <div>
        <p>Elevation</p>
        <p>
          {{ spring.elevation.toLocaleString() }} ft
        </p>
      </div>
    </div>

    <div>
      <h2>Features</h2>
      <div>
        <span v-for="feature in spring.features" :key="feature">
          {{ feature }}
        </span>
      </div>
    </div>
  </div>
</template>
```


---

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