Skip to content
Drivn
6 min read

Next.js Pagination with Server Components

Add pagination to a Next.js App Router app. Drivn ships a stateless component of native anchor links that reads the current page from searchParams.

Pagination is a navigation problem the Next.js App Router solves with the URL. When a list spans dozens of pages, the current page belongs in the query string — ?page=2 — so the server renders the right slice, the browser can bookmark it, and search engines crawl every page as its own indexable URL. Drivn's Pagination leans into that model: it is a stateless row of native anchor links, so the page number never lives in React state at all.

After install the component sits in src/components/ui/pagination.tsx as a family of functions wired together with Object.assign — the root <nav> plus .Content, .Item, .Link, .Previous, .Next, and .Ellipsis. There is no 'use client' directive, no useState, no effect: every clickable element is an <a href> styled from a single styles object, and the isActive prop on .Link marks the current page with aria-current="page". That lets the whole control render inside a Server Component with zero client bundle cost.

This guide installs Drivn in a Next.js 16 project, renders the pagination bar, drives the active page from searchParams, swaps native anchors for Next's <Link> to get soft navigation, and truncates long ranges with the Ellipsis. Every snippet comes from the component's real API. For the full reference see the Pagination docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Pagination.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add pagination. The component imports ChevronLeft, ChevronRight, and MoreHorizontal from lucide-react for the previous, next, and ellipsis glyphs, so keep that package installed; the CLI writes a single pagination.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a route.

1# from the root of your Next.js 16 project
2npx drivn add pagination

Render the bar in a Server Component

Because every clickable element is a native <a href> and the component holds no state, pagination.tsx carries no 'use client' directive — it renders inside a Server Component as part of the static HTML. The root renders a <nav aria-label="pagination">, Pagination.Content renders the <ul> row, and each Pagination.Item wraps a single control: a Pagination.Previous, a numbered Pagination.Link, or a Pagination.Next. Mark the current page by passing isActive to its Pagination.Link, which applies the styles.active classes and sets aria-current="page" so assistive technology announces the position. Drop the bar at the bottom of any list route and it works immediately; pair it with the Drivn Data Table when the list above it is a sortable grid.

1import { Pagination } from '@/components/ui/pagination'
2
3export default function ListPage() {
4 return (
5 <Pagination>
6 <Pagination.Content>
7 <Pagination.Item>
8 <Pagination.Previous href="#" />
9 </Pagination.Item>
10 <Pagination.Item>
11 <Pagination.Link href="#">1</Pagination.Link>
12 </Pagination.Item>
13 <Pagination.Item>
14 <Pagination.Link href="#" isActive>
15 2
16 </Pagination.Link>
17 </Pagination.Item>
18 <Pagination.Item>
19 <Pagination.Link href="#">3</Pagination.Link>
20 </Pagination.Item>
21 <Pagination.Item>
22 <Pagination.Next href="#" />
23 </Pagination.Item>
24 </Pagination.Content>
25 </Pagination>
26 )
27}

Drive the active page from searchParams

In the App Router the current page is a search parameter, not component state. A page component receives searchParams as a prop, so read ?page= from it, coerce it to a number, fetch the matching slice on the server, and build each href as ?page=N. Set isActive on the link whose number equals the current page so the aria-current="page" marker and the styles.active highlight track the URL exactly. Because the component is a plain server render, there is no useEffect to sync the URL with state and no hydration mismatch — the highlighted page is whatever the server read from the request. This is also why the pattern is SEO-friendly: every page is a distinct, crawlable URL rather than a client-side state the crawler cannot reach. Build the number range from your total count, and reach for the Data Table or a plain server map to render the rows above the bar.

1import { Pagination } from '@/components/ui/pagination'
2
3export default async function PostsPage({
4 searchParams,
5}: {
6 searchParams: Promise<{ page?: string }>
7}) {
8 const { page } = await searchParams
9 const current = Number(page) || 1
10 const totalPages = 5
11 const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
12 return (
13 <Pagination>
14 <Pagination.Content>
15 <Pagination.Item>
16 <Pagination.Previous
17 href={`?page=${Math.max(1, current - 1)}`}
18 />
19 </Pagination.Item>
20 {pages.map((p) => (
21 <Pagination.Item key={p}>
22 <Pagination.Link href={`?page=${p}`} isActive={p === current}>
23 {p}
24 </Pagination.Link>
25 </Pagination.Item>
26 ))}
27 <Pagination.Item>
28 <Pagination.Next
29 href={`?page=${Math.min(totalPages, current + 1)}`}
30 />
31 </Pagination.Item>
32 </Pagination.Content>
33 </Pagination>
34 )
35}

Swap native anchors for soft navigation

Each Pagination.Link, Previous, and Next renders a native <a href>, which performs a full-document navigation. For the soft, client-side transitions the App Router is known for — keeping scroll position and avoiding a full reload — swap the <a> for Next's <Link> inside the component source you now own. Open pagination.tsx, import Link from 'next/link', and replace the <a> in the Link function with a Next <Link> that keeps the same cn(styles.link, isActive && styles.active, className) classes and spreads the rest of the props. Do the same in Previous and Next if you want every control to soft-navigate. The edit lives in one file, so every pagination bar in your app upgrades at once. For a filtered or sorted list you can also call router.push from a Client Component, but for pure page links the <Link> swap is the smaller change.

1// pagination.tsx — swap the native anchor for Next's Link
2import NextLink from 'next/link'
3
4function Link({
5 isActive,
6 className,
7 href,
8 ...props
9}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
10 isActive?: boolean
11}) {
12 return (
13 <NextLink
14 href={href ?? '#'}
15 aria-current={isActive ? 'page' : undefined}
16 className={cn(styles.link, isActive && styles.active, className)}
17 {...props}
18 />
19 )
20}

Truncate long ranges and restyle

When a list runs to fifty pages you do not render fifty links — you show the first pages, an ellipsis, and the last pages. Drop a Pagination.Ellipsis into a Pagination.Item between the numbers you want to skip; it renders a MoreHorizontal glyph plus an sr-only "More pages" label from the styles.ellipsis class, so the gap reads correctly to a screen reader. Everything visual lives in one styles object at the top of the file: styles.link for the resting number, styles.activebg-foreground text-background — for the current page, and styles.nav_link for the Previous and Next controls. Because those classes read Tailwind tokens like bg-accent and text-muted-foreground, changing them in theming re-themes the whole bar for dark and light mode at once. For copy-paste ranges and layouts see the Pagination examples.

1// styles.active — current-page highlight (verbatim from pagination.tsx)
2active: cn(
3 'bg-foreground text-background',
4 'hover:bg-foreground hover:text-background'
5),
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. The Pagination source has no 'use client' directive because every control is a native <a href> and the component holds no state and runs no effect. Render it directly inside an App Router page.tsx and it streams as part of the static HTML with no client bundle cost. Only the surrounding data fetch runs on the server too, so the highlighted page reflects whatever the server read from searchParams. If you swap the anchors for Next's <Link> the file stays server-renderable because <Link> itself works in a Server Component.

Pass the isActive prop to the Pagination.Link whose number matches the current page. It applies the styles.active classes — bg-foreground text-background — for the highlight and sets aria-current="page" so assistive technology announces which page is active. Drive it from the URL by comparing each link's number to the ?page= value you read from searchParams, for example isActive={p === current}. Because the marker is derived from the request rather than from React state, the highlight always matches the page the server rendered.

The built-in Pagination.Link renders a native <a href>, which triggers a full-document navigation. To get the App Router's soft transitions, open the pagination.tsx file you own after install and replace the <a> inside the Link, Previous, and Next functions with Next's <Link>, keeping the same cn(styles.link, ...) classes and prop spread. The change lives in one file and upgrades every pagination bar in your app at once. For filtered lists you can also call router.push from a Client Component instead.

Render a Pagination.Ellipsis inside a Pagination.Item in place of the page numbers you want to skip. It draws a MoreHorizontal icon with an sr-only "More pages" label from the styles.ellipsis class, so the truncation reads correctly to screen readers. A common shape is first page, ellipsis, the pages around the current one, ellipsis, last page. Compute which numbers to show from the current page and the total, then render the surrounding numbers as Pagination.Link items and the gaps as Pagination.Ellipsis.

Open pagination.tsx after install and edit the styles object at the top — styles.link for the resting numbers, styles.active for the current page, styles.nav_link for Previous and Next, and styles.ellipsis for the gap. Bump h-9 min-w-9 for larger targets or swap bg-foreground text-background for a branded highlight. Because the classes read Tailwind tokens like bg-accent and text-muted-foreground, changing those tokens in theming re-themes every pagination bar for dark and light mode. The whole visual surface lives in one object, so a size or color change is a single edit.