Dynamic Routes in Next.js: Building Detail Pages with [id]

Step 10 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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

  1. What is a Dynamic Route?
  2. Core Concept: The [id] Folder Convention
  3. Core Concept: params and How Next.js Passes Route Parameters
  4. Core Concept: findUnique() vs findMany()
  5. Create the Post Detail Page
  6. Add Links to the Posts List
  7. Understanding the Code
  8. Verify the Result
  9. Summary & Key Takeaways

What is a Dynamic Route?

↑ Index

So far, every page in our app has a fixed URL:

FileURL
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."

URLid value
/posts/1"1"
/posts/42"42"
/posts/hello"hello"

Core Concept: The [id] Folder Convention

↑ Index

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

↑ Index

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:

  • params is a Promise — you must await it before accessing properties.
  • The value is always a string, even if the URL looks like a number. /posts/42 gives you "42", not 42.
  • If you need a number (for a database query), you must convert it: Number(id) or parseInt(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()

↑ Index

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 },
})
MethodReturnsWhen no match
findMany()Array of recordsEmpty array []
findUnique()Single record or nullnull
findUniqueOrThrow()Single recordThrows 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

PrismaLaravelBehavior
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

↑ Index

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"
      >
        &larr; 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'} &middot;{' '}
          {new Date(post.createdAt).toLocaleDateString()}
        </p>
        <div className="prose max-w-none text-gray-700">
          <p>{post.content}</p>
        </div>
      </article>
    </>
  )
}

↑ Index

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} &middot;{' '}
              {new Date(post.createdAt).toLocaleDateString()}
            </p>
            <p className="text-gray-700">{post.content}</p>
          </div>
        ))}
      </div>
    </>
  )
}

Understanding the Code

↑ Index

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, or undefined if author is null or name is null.
  • ?? 'Unknown' — nullish coalescing. If the left side is null or undefined, use 'Unknown' instead.

Together: "Show the author's name, or 'Unknown' if there is no author or no name."

<Link href="/posts" className="...">
  &larr; Back to all posts
</Link>

&larr; 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

↑ Index

Make sure your dev server is running:

pnpm dev

Test the detail page:

  1. Navigate to http://localhost:3000/posts.
  2. Click any post title — you should be taken to /posts/1 (or whichever id).
  3. The page shows the full post title, author name, date, and content.
  4. 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

↑ Index

ConceptWhat it means
[id] folderA dynamic route segment — captures any value from the URL
paramsA 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.