Client Components & Pagination in Next.js with React Hooks

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

Live Demo →


What You Will Build

↑ Index

By the end of this step, the /posts page will show 5 posts per page with "Previous" and "Next" buttons. Clicking a button fetches the next batch from the API — without a full page reload.

This is our first Client Component. Until now, every component has been a Server Component (rendered on the server). Pagination requires interactivity (button clicks, state changes), which means we need to run code in the browser.

Goal: Understand the boundary between Server and Client Components, and build a paginated posts list using useState, useEffect, and the GET /api/posts endpoint.


Table of Contents

  1. Server Components vs Client Components
  2. Core Concept: 'use client'
  3. Core Concept: useState
  4. Core Concept: useEffect
  5. Update the API to Support Pagination
  6. Build the Paginated Posts Page
  7. Understanding the Code
  8. How the Data Flows
  9. Verify the Result
  10. Common Mistakes
  11. Summary & Key Takeaways

Server Components vs Client Components

↑ Index

Every component in Next.js is a Server Component by default. Here is the key difference:

Server ComponentClient Component
Runs whereOn the server onlyOn the server (initial render) and in the browser
Can useasync/await, direct database access, file systemuseState, useEffect, event handlers, browser APIs
Cannot useuseState, useEffect, onClickDirect database access (prisma), fs
DirectiveNone (default)'use client' at the top of the file

Until now, all our components have been Server Components. The posts page in Step 8 queries the database directly:

// Server Component — runs on the server
export default async function PostsPage() {
  const posts = await prisma.post.findMany({ ... })
  return <div>{/* render posts */}</div>
}

This works great for static content. But pagination needs interactivity — the user clicks a button, the page number changes, and new data is fetched. That requires browser-side state management, which Server Components cannot do.

When to use each

Use a Server Component when...Use a Client Component when...
The page just displays dataThe user interacts with the page (clicks, typing, toggling)
You need direct database accessYou need useState, useEffect, or event handlers
You want to keep secrets server-sideYou need browser APIs (window, localStorage)
SEO matters (content in initial HTML)The content changes after the page loads

Core Concept: 'use client'

↑ Index

To make a component a Client Component, add the 'use client' directive as the very first line of the file:

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Important rules:

  • 'use client' must be the first line of the file — before any imports.
  • It is a file-level directive. Every component exported from that file becomes a Client Component.
  • A Client Component can import and render Server Components, but the Server Component will run on the server and the result will be passed to the client.
  • You do not need 'use client' on every file. Only add it where you actually need browser interactivity.

Core Concept: useState

↑ Index

useState is a React hook that creates a piece of state — a value that persists across renders and triggers a re-render when it changes.

const [page, setPage] = useState(1)

This creates:

  • page — the current value (starts at 1)
  • setPage — a function to update the value

When you call setPage(2), React re-renders the component with page now equal to 2.

// Reading state
<p>Page {page}</p>

// Updating state
<button onClick={() => setPage(page + 1)}>Next</button>

Why not just use a variable?

// ❌ This does NOT work — re-renders reset the variable
let page = 1
page = 2 // component re-renders, page goes back to 1

// ✅ useState persists the value across re-renders
const [page, setPage] = useState(1)
setPage(2) // component re-renders, page is now 2

Regular variables are re-created every time the component renders. useState stores the value outside the render cycle so it survives re-renders.


Core Concept: useEffect

↑ Index

useEffect runs a function after the component renders. It is used for side effects — things that happen outside of rendering, like fetching data, setting up timers, or updating the document title.

useEffect(() => {
  // This runs after every render
  console.log('Component rendered')
})

The dependency array

The second argument controls when the effect runs:

// Runs after EVERY render
useEffect(() => { ... })

// Runs only ONCE — when the component first mounts
useEffect(() => { ... }, [])

// Runs when `page` changes
useEffect(() => { ... }, [page])

The dependency array [page] tells React: "Only re-run this effect when page changes." This is exactly what we need for pagination — when the user clicks "Next" and page changes from 1 to 2, we want to fetch new data.

Fetching data with useEffect

const [posts, setPosts] = useState([])
const [page, setPage] = useState(1)

useEffect(() => {
  fetch(`/api/posts?take=5&skip=${(page - 1) * 5}`)
    .then((res) => res.json())
    .then((data) => setPosts(data))
}, [page])

Every time page changes, this effect:

  1. Calls the API with the correct skip value
  2. Parses the JSON response
  3. Updates posts state with the new data
  4. React re-renders the component with the new posts

Update the API to Support Pagination

↑ Index

Before building the UI, we need to update the GET /api/posts endpoint to support both take and skip parameters, and also return the total count so the UI knows when there are no more pages.

Update app/api/posts/route.ts:

import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const take = Number(searchParams.get('take')) || undefined
  const skip = Number(searchParams.get('skip')) || undefined

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      include: { author: true },
      orderBy: { createdAt: 'desc' },
      ...(take && { take }),
      ...(skip && { skip }),
    }),
    prisma.post.count(),
  ])

  return NextResponse.json({ posts, total })
}

export async function POST(request: Request) {
  try {
    const body = await request.json()

    if (!body.title || !body.content) {
      return NextResponse.json(
        { error: 'Title and content are required' },
        { status: 400 },
      )
    }

    const post = await prisma.post.create({
      data: {
        title: body.title,
        content: body.content,
        authorId: body.authorId,
      },
      include: { author: true },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 },
    )
  }
}

What changed in the GET handler

  1. Added skip query parameter support alongside take.
  2. Used Promise.all to run two queries in parallel: one for the posts, one for the total count.
  3. The response shape changed from an array to { posts: [...], total: 14 }.

Why Promise.all?

const [posts, total] = await Promise.all([
  prisma.post.findMany({ ... }),
  prisma.post.count(),
])

Promise.all runs both database queries at the same time instead of one after the other. Without it, you would write:

// ❌ Sequential — total waits for posts to finish
const posts = await prisma.post.findMany({ ... })
const total = await prisma.post.count()

// ✅ Parallel — both queries run at the same time
const [posts, total] = await Promise.all([
  prisma.post.findMany({ ... }),
  prisma.post.count(),
])

The parallel version is faster because the database processes both queries simultaneously.

Test the updated endpoint:

# First 5 posts
curl "http://localhost:3000/api/posts?take=5&skip=0"

# Next 5 posts
curl "http://localhost:3000/api/posts?take=5&skip=5"

The response now looks like:

{
  "posts": [ ... ],
  "total": 15
}

Build the Paginated Posts Page

↑ Index

Now we replace the Server Component posts page with a Client Component that uses pagination.

Replace the entire contents of app/posts/page.tsx:

'use client'

import { useEffect, useState } from 'react'
import Link from 'next/link'

const POSTS_PER_PAGE = 5

type Author = {
  id: string
  name: string | null
  email: string
}

type Post = {
  id: number
  title: string
  content: string
  createdAt: string
  author: Author | null
}

export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([])
  const [total, setTotal] = useState(0)
  const [page, setPage] = useState(1)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    setLoading(true)

    const skip = (page - 1) * POSTS_PER_PAGE

    fetch(`/api/posts?take=${POSTS_PER_PAGE}&skip=${skip}`)
      .then((res) => res.json())
      .then((data) => {
        setPosts(data.posts)
        setTotal(data.total)
        setLoading(false)
      })
  }, [page])

  const totalPages = Math.ceil(total / POSTS_PER_PAGE)

  return (
    <>
      <h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
      <p className="mb-8 text-gray-600">
        {total} posts &middot; Page {page} of {totalPages}
      </p>

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

      <div className="mt-8 flex items-center gap-4">
        <button
          onClick={() => setPage(page - 1)}
          disabled={page <= 1}
          className="rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-50"
        >
          &larr; Previous
        </button>

        <span className="text-sm text-gray-600">
          Page {page} of {totalPages}
        </span>

        <button
          onClick={() => setPage(page + 1)}
          disabled={page >= totalPages}
          className="rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-50"
        >
          Next &rarr;
        </button>
      </div>
    </>
  )
}

Understanding the Code

↑ Index

The 'use client' directive

'use client'

This is the first line of the file. It tells Next.js: "This component needs to run in the browser." Without it, React would refuse to use useState and useEffect because those hooks only work in Client Components.

The type definitions

type Author = {
  id: string
  name: string | null
  email: string
}

type Post = {
  id: number
  title: string
  content: string
  createdAt: string
  author: Author | null
}

In the Server Component version (Step 8), we didn't define any types as Prisma inferred the types automatically. But this Client Component fetches data from the API using fetch() — Prisma is not involved here. So we define the types manually.

Notice that createdAt is string, not Date. When data travels through JSON (from API to client), dates are serialized as strings. The server sends "2026-03-12T00:00:00.000Z" and the client receives it as a string, not a Date object. That is why we still need new Date(post.createdAt) when displaying it.

The state variables

const [posts, setPosts] = useState<Post[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)

Four pieces of state:

StatePurposeInitial value
postsThe current page of posts[] (empty array)
totalTotal number of posts in the database0
pageThe current page number1
loadingWhether a fetch is in progresstrue

The <Post[]> syntax in useState<Post[]>([]) is a generic type parameter. It tells TypeScript the type of the state. Without it, TypeScript would infer never[] from the empty array and complain when you try to set real posts.

The useEffect hook

useEffect(() => {
  setLoading(true)

  const skip = (page - 1) * POSTS_PER_PAGE

  fetch(`/api/posts?take=${POSTS_PER_PAGE}&skip=${skip}`)
    .then((res) => res.json())
    .then((data) => {
      setPosts(data.posts)
      setTotal(data.total)
      setLoading(false)
    })
}, [page])

The dependency array [page] means this effect runs:

  1. Once when the component first mounts (page = 1)
  2. Again every time page changes

Inside the effect:

  1. Set loading to true to show the loading indicator
  2. Calculate skip — page 1 skips 0, page 2 skips 5, page 3 skips 10
  3. Call the API with take and skip query parameters
  4. Parse the JSON response
  5. Update both posts and total state
  6. Set loading to false to hide the loading indicator

The pagination math

const skip = (page - 1) * POSTS_PER_PAGE
const totalPages = Math.ceil(total / POSTS_PER_PAGE)

With POSTS_PER_PAGE = 5 and total = 14:

PageskipPosts shownCalculation
101–5(1 - 1) * 5 = 0
256–10(2 - 1) * 5 = 5
31011–14(3 - 1) * 5 = 10

Math.ceil(14 / 5) = Math.ceil(2.8) = 3 total pages.

Disabling buttons

<button
  onClick={() => setPage(page - 1)}
  disabled={page <= 1}
>

The disabled attribute prevents the user from going to page 0 or below. When disabled is true, the button cannot be clicked and the disabled:opacity-50 disabled:cursor-not-allowed Tailwind classes apply visual styling.

The loading state

{loading ? (
  <p className="text-gray-500">Loading...</p>
) : (
  <div className="space-y-4">
    {posts.map((post) => ( ... ))}
  </div>
)}

This is a conditional render using the ternary operator. While loading is true, we show "Loading...". Once the fetch completes and loading becomes false, we show the posts list. This prevents the UI from briefly showing an empty list while data is being fetched.

The metadata change

Notice that the export const metadata and export async function generateMetadata() exports are gone. Client Components cannot export metadata — that is a Server Component feature. The page will use the metadata from the nearest parent layout instead.

If you need metadata on a paginated page, you can wrap the Client Component inside a Server Component:

// app/posts/page.tsx (Server Component)
import type { Metadata } from 'next'
import PostsList from './PostsList'

export const metadata: Metadata = {
  title: 'All Posts — Superblog',
  description: 'Browse all blog posts on Superblog',
}

export default function PostsPage() {
  return <PostsList />
}
// app/posts/PostsList.tsx (Client Component)
'use client'
// ... the pagination component

This pattern is common: the page file stays a Server Component (for metadata and layout), and the interactive part is extracted into a separate Client Component.


How the Data Flows

↑ Index

Here is the complete journey when a user clicks "Next":

1. User clicks "Next" button
   ↓
2. setPage(2) is called
   ↓
3. React re-renders the component (page is now 2)
   ↓
4. useEffect detects that [page] changed
   ↓
5. setLoading(true) → UI shows "Loading..."
   ↓
6. fetch('/api/posts?take=5&skip=5') sends a GET request
   ↓
7. Next.js Route Handler runs on the server
   ↓
8. Prisma queries the database: SELECT ... LIMIT 5 OFFSET 5
   ↓
9. API returns JSON: { posts: [...], total: 14 }
   ↓
10. setPosts(data.posts) and setTotal(data.total) update state
   ↓
11. setLoading(false) → UI renders the new posts

This all happens without a full page reload — only the posts list updates.


Verify the Result

↑ Index

Make sure your dev server is running:

pnpm dev
  1. Navigate to http://localhost:3000/posts.
  2. You should see 5 posts and pagination controls at the bottom.
  3. The page info should show "14 posts - Page 1 of 3".
  4. Click "Next" — page 2 loads with the next 5 posts.
  5. Click "Next" again — page 3 loads with the remaining 4 posts.
  6. The "Next" button should be disabled on page 3.
  7. Click "Previous" to go back. The "Previous" button should be disabled on page 1.
  8. Click a post title — it should navigate to the detail page (Step 10).

Common Mistakes

↑ Index

Forgetting 'use client'

Error: useState only works in Client Components.
Add the "use client" directive at the top of the file.

If you see this error, you forgot the 'use client' directive at the top of the file.

Using Prisma in a Client Component

// ❌ This will NOT work — Prisma cannot run in the browser
'use client'
import prisma from '@/lib/prisma'

export default function PostsPage() {
  const posts = await prisma.post.findMany()
}

Prisma is a server-only library. It talks directly to the database, which the browser cannot do. In a Client Component, you must fetch data through an API endpoint using fetch().

Not handling the loading state

// ❌ Posts array is empty on first render — UI flashes empty
const [posts, setPosts] = useState<Post[]>([])
return posts.map((post) => <div>{post.title}</div>)

Without a loading state, the UI briefly shows nothing before the data arrives. Always use a loading indicator for a better user experience.

Type mismatch with dates

// ❌ TypeScript error if you type createdAt as Date
type Post = {
  createdAt: Date // Wrong! JSON serializes dates as strings
}

// ✅ Correct — dates from API responses are strings
type Post = {
  createdAt: string
}

When data passes through JSON.stringify() (on the server) and JSON.parse() (on the client via res.json()), Date objects become strings. Always use string for date fields in API response types.


Summary & Key Takeaways

↑ Index

ConceptWhat it means
Server ComponentDefault in Next.js. Runs on the server. Can use async/await and Prisma directly
Client ComponentMarked with 'use client'. Runs in the browser. Can use hooks and event handlers
useStateCreates a piece of state that persists across renders and triggers re-renders
useEffectRuns a side effect after render. The dependency array controls when it re-runs
fetch()How Client Components get data — through API endpoints, not direct database access
Promise.allRuns multiple async operations in parallel for better performance
Pagination mathskip = (page - 1) * perPage, totalPages = Math.ceil(total / perPage)

Looking ahead: In Step 20, we refactor this pagination from useEffect and API calls to a Server Component approach using await, Suspense, and URL-based pagination — eliminating the need for a Client Component entirely. In Step 21, we then use React 19's use() hook for a feature that genuinely needs both server data and client interactivity.

What is Next

In Step 13, we will add authentication to the app using NextAuth.js. This will let users sign in, and later we will restrict post creation and deletion to authenticated users only.