Dynamic Routes in Next.js: Building Detail Pages with [id]
Step 10 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
By the end of this step, clicking a post title on the /posts page will navigate to /posts/1, /posts/2, etc. Each URL will show the full post detail — title, content, author name, and creation date — fetched from the database using a dynamic route.
Goal: Create app/posts/[id]/page.tsx — a dynamic Server Component that reads the id from the URL and fetches the matching post from the database.
Table of Contents
- What is a Dynamic Route?
- Core Concept: The
[id]Folder Convention - Core Concept:
paramsand How Next.js Passes Route Parameters - Core Concept:
findUnique()vsfindMany() - Create the Post Detail Page
- Add Links to the Posts List
- Understanding the Code
- Verify the Result
- Summary & Key Takeaways
What is a Dynamic Route?
So far, every page in our app has a fixed URL:
| File | URL |
|---|---|
app/page.tsx | / |
app/posts/page.tsx | /posts |
But a blog needs URLs like /posts/1, /posts/2, /posts/42 — where the last segment changes depending on which post we want to view. We cannot create a separate file for every post. Instead, we need a dynamic route — a single file that handles all possible values of that segment.
In Laravel, you would write in routes file:
// Laravel — routes/web.php
Route::get('/posts/{id}', [PostController::class, 'show']);
In Next.js, there is no routes file. Instead, you create a folder with square brackets:
app/posts/[id]/page.tsx
The [id] folder name tells Next.js: "This segment is dynamic. Whatever value appears in the URL, capture it as id."
| URL | id value |
|---|---|
/posts/1 | "1" |
/posts/42 | "42" |
/posts/hello | "hello" |
Core Concept: The [id] Folder Convention
The square brackets are not optional syntax — they are literal characters in the folder name. Your file system should look like this:
app/
├── posts/
│ ├── page.tsx ← /posts (list page)
│ └── [id]/
│ └── page.tsx ← /posts/:id (detail page)
You can name the parameter anything: [id], [slug], [postId]. The name inside the brackets becomes the key in the params object. We use [id] because our Post model uses id as the primary key.
Core Concept: params and How Next.js Passes Route Parameters
In Next.js 15+, every page component can receive a params prop that contains the dynamic segments from the URL. In the latest versions, params is a Promise that you must await:
type Props = {
params: Promise<{ id: string }>
}
export default async function PostPage({ params }: Props) {
const { id } = await params
// id is always a string, e.g. "1", "42", "hello"
}
Important details:
paramsis aPromise— you mustawaitit before accessing properties.- The value is always a string, even if the URL looks like a number.
/posts/42gives you"42", not42. - If you need a number (for a database query), you must convert it:
Number(id)orparseInt(id).
Resolving the post instance from id
In Next.js, you do the conversion from id to post:
const { id } = await params
const post = await prisma.post.findUnique({
where: { id: Number(id) },
})
Core Concept: findUnique() vs findMany()
In Step 8, we used findMany() to fetch all posts. For the detail page, we need exactly one post. Prisma provides findUnique() for this:
const post = await prisma.post.findUnique({
where: { id: 1 },
})
| Method | Returns | When no match |
|---|---|---|
findMany() | Array of records | Empty array [] |
findUnique() | Single record or null | null |
findUniqueOrThrow() | Single record | Throws an error |
findUnique() only works with fields that have a unique constraint — like id or email (fields marked with @id or @unique in the schema). For non-unique fields, use findFirst() instead.
Laravel comparison
| Prisma | Laravel | Behavior |
|---|---|---|
findUnique() | Post::find($id) | Returns null if not found |
findUniqueOrThrow() | Post::findOrFail($id) | Throws an error if not found |
findFirst() | Post::where(...)->first() | Returns first match or null |
findMany() | Post::all() / Post::get() | Returns a collection |
Create the Post Detail Page
Create the folder and file:
mkdir -p app/posts/\[id\]
Then create app/posts/[id]/page.tsx with the following content:
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import prisma from '@/lib/prisma'
type Props = {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id: Number(id) },
})
return {
title: post ? `${post.title} — Superblog` : 'Post Not Found',
}
}
export default async function PostPage({ params }: Props) {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true },
})
if (!post) {
notFound()
}
return (
<>
<Link
href="/posts"
className="mb-6 inline-block text-sm text-teal-600 hover:text-teal-700"
>
← Back to all posts
</Link>
<article className="rounded-lg bg-white p-8 shadow-md">
<h2 className="mb-2 text-3xl font-bold text-gray-900">{post.title}</h2>
<p className="mb-6 text-sm text-gray-500">
By {post.author?.name ?? 'Unknown'} ·{' '}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<div className="prose max-w-none text-gray-700">
<p>{post.content}</p>
</div>
</article>
</>
)
}
Add Links to the Posts List
Now update app/posts/page.tsx to make each post title a clickable link. You need two changes:
1. Add the Link import at the top of the file:
import Link from 'next/link'
2. Wrap the post title in a Link component. Replace the <h3> in the posts map:
<h3 className="mb-1 text-xl font-semibold text-gray-800">
{post.title}
</h3>
with:
<Link href={`/posts/${post.id}`}>
<h3 className="mb-1 text-xl font-semibold text-teal-600 hover:text-teal-700">
{post.title}
</h3>
</Link>
The complete updated app/posts/page.tsx:
import type { Metadata } from 'next'
import Link from 'next/link'
import prisma from '@/lib/prisma'
export const metadata: Metadata = {
title: 'All Posts — Superblog',
description: 'Browse all blog posts on Superblog',
}
export default async function PostsPage() {
const posts = await prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
})
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
<p className="mb-8 text-gray-600">{posts.length} posts</p>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="rounded-lg bg-white p-6 shadow-md">
<Link href={`/posts/${post.id}`}>
<h3 className="mb-1 text-xl font-semibold text-teal-600 hover:text-teal-700">
{post.title}
</h3>
</Link>
<p className="mb-3 text-sm text-gray-500">
By {post.author?.name} ·{' '}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<p className="text-gray-700">{post.content}</p>
</div>
))}
</div>
</>
)
}
Understanding the Code
The Props type
type Props = {
params: Promise<{ id: string }>
}
This tells TypeScript that the component receives a params prop which is a Promise containing an object with an id string. In Next.js 15+, params is asynchronous — you must await it.
Awaiting params
const { id } = await params
We destructure id from the awaited params object. Since id is always a string from the URL, we convert it to a number when querying:
where: { id: Number(id) }
Our Post model uses Int for the id field (@id @default(autoincrement())), so we need Number() to convert "42" to 42.
The notFound() function
if (!post) {
notFound()
}
notFound() is imported from next/navigation. When called, it immediately renders the nearest not-found.tsx page (or a default 404 page).
We use findUnique() + notFound() instead of findUniqueOrThrow() because notFound() gives us control over the 404 behavior — we can customize the error page later.
The generateMetadata function
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id: Number(id) },
})
return {
title: post ? `${post.title} — Superblog` : 'Post Not Found',
}
}
This is a special Next.js function that generates dynamic metadata (page title, description) based on the URL. It runs before the component renders, so the browser tab shows the post title instead of a generic string.
In Step 8, we used a static export const metadata object. Here, we need a function because the title depends on which post is being viewed.
The ?? operator (nullish coalescing)
By {post.author?.name ?? 'Unknown'}
This combines two operators:
post.author?.name— optional chaining. Returns the name if it exists, orundefinedifauthoris null ornameis null.?? 'Unknown'— nullish coalescing. If the left side isnullorundefined, use'Unknown'instead.
Together: "Show the author's name, or 'Unknown' if there is no author or no name."
The back link
<Link href="/posts" className="...">
← Back to all posts
</Link>
← is the HTML entity for a left arrow: ←. The Link component enables client-side navigation back to the posts list without a full page reload.
Verify the Result
Make sure your dev server is running:
pnpm dev
Test the detail page:
- Navigate to
http://localhost:3000/posts. - Click any post title — you should be taken to
/posts/1(or whicheverid). - The page shows the full post title, author name, date, and content.
- Click "Back to all posts" to return to the list.
Test the 404:
Navigate to http://localhost:3000/posts/99999 (a non-existent ID). You should see a 404 page.
Test the browser tab:
On a post detail page, the browser tab should show the post title (e.g., "My First Post — Superblog") instead of a generic title. This confirms generateMetadata is working.
Summary & Key Takeaways
| Concept | What it means |
|---|---|
[id] folder | A dynamic route segment — captures any value from the URL |
params | A Promise containing the dynamic segments; must be awaited |
findUnique() | Fetches a single record by a unique field; returns null if not found |
notFound() | Renders a 404 page — similar to Laravel abort(404) |
generateMetadata() | Dynamic metadata for SEO — runs before the component renders |
Number(id) | Converts the string param to a number for the database query |
What is Next
In Step 11, we will build an API route using Next.js Route Handlers. This lets other clients (mobile apps, external services, or our own frontend) fetch posts as JSON via GET /api/posts.