Dynamic Routes
A detail page maps one URL to one record and returns a 404 when that record does not exist.
In Next.js, this pattern uses params and a Server Component. The Nuxt version combines useRoute(), useFetch, and createError.
Outcome
Build a detail page that displays full information for a single hot spring.
Fast Track
- Create the server route
server/api/springs/[id].get.tsthat returns a single spring - Update
app/pages/springs/[id].vueto fetch and display the spring - 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:
- Create
server/api/springs/[id].get.tsthat finds a spring by ID and returns it - Throw a 404 error if the spring doesn't exist
- Update
app/pages/springs/[id].vueto fetch the spring and display its full details - Show location, temperature, elevation, and features
- Include a back link to the browse page
Implementation hints:
getRouterParam(event, "id")extracts route params in server routes. It's the server equivalent ofuseRoute().params.idcreateError({ statusCode: 404 })throws an error that Nuxt catches and renders as an error pageawait useFetch(...)withawaitblocks page rendering until the data loads. Withoutawait, the page renders immediately with null data
Let's start with the server route. We need to find a single spring by its ID:
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.
Next, build the detail page:
<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>With await useFetch, Nuxt waits for the data before rendering the page. Without await, spring starts as null and the template needs a loading state. This detail page waits because all of its content depends on the response.
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:
<template>
<div v-if="spring">
<NuxtLink to="/springs">
← 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.
Try It
Visit http://localhost:3000/springs/breitenbush-hot-springs. You should see:
- A back link to the browse page
- The spring name ("Breitenbush Hot Springs") with a "developed" badge
- The full description
- A three-column grid showing location, temperature range, and elevation
- 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
git add -A && git commit -m "feat(detail): add single spring API route and detail page"Done-When
/api/springs/breitenbush-hot-springsreturns a single spring as JSON/api/springs/nonexistent-springreturns 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
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;
});<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">
← 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>Was this helpful?