Refactoring Pagination: From useEffect to Server Components & Suspense

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

Live Demo →


What You Will Build

↑ Index

In Step 12, we built the posts listing page as a Client Component using useEffect and useState to fetch paginated data from an API route. It works — but it has real drawbacks.

In this step, you will refactor that page into a Server Component architecture:

  • Replace useEffect fetching with a direct Prisma query in a Server Component
  • Replace useState pagination with URL-based pagination using searchParams
  • Wrap the data-dependent UI in Suspense for streaming

By the end, the posts page will load faster, be fully SEO-friendly, and have simpler code.


Table of Contents

  1. The Problem with useEffect Fetching
  2. The Solution: Server Components + URL Pagination
  3. But Wait — Didn't We Need Interactivity?
  4. use() Hook vs. await in Server Components
  5. Step-by-Step Refactor
  6. Understanding Suspense and Streaming
  7. Before vs. After: Architecture Comparison
  8. Performance Benefits
  9. Summary & Key Takeaways

The Problem with useEffect Fetching

↑ Index

Here is the current app/posts/page.tsx (built in Step 12):

"use client";

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

const POSTS_PER_PAGE = 5;

export default function PostsPage() {
  const [posts, setPosts] = useState([]);
  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]);

  // ... render posts, pagination buttons
}

This pattern has several issues:

ProblemWhy it matters
Double renderThe component renders once empty, then re-renders after data arrives
No SEOSearch engines see an empty page — the content loads only after JavaScript runs
Extra API layerThe page fetches from /api/posts, which calls Prisma — an unnecessary round-trip since both run on the same server
Manual loading stateYou must manage loading, setLoading(true), setLoading(false) manually
Client bundle sizeThe entire component ships to the browser as JavaScript

If you've read the React tutorial on use() vs useEffect, you'll recognize this as the classic "render → fetch → re-render" pattern that React 19's new features aim to solve.


The Solution: Server Components + URL Pagination

↑ Index

The refactored architecture looks like this:

Before (Step 12):
  Client Component → useEffect → fetch(/api/posts) → API Route → Prisma → DB

After (Step 20):
  Server Component → Prisma → DB → HTML sent to browser

Instead of fetching data on the client, we:

  1. Remove "use client" — make the page a Server Component
  2. Query Prisma directly — no API route needed
  3. Use searchParams for pagination — ?page=2 instead of useState
  4. Use Suspense — React streams the page shell immediately and fills in data when ready

But Wait — Didn't We Need Interactivity?

↑ Index

In Step 12, we justified making the posts page a Client Component with this reasoning:

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

That was true — given the approach we chose. In step 12, pagination worked like this:

User clicks "Next" → onClick handler → setPage(2) → useEffect fires → fetch new data → re-render

Every piece of this chain requires client-side JavaScript: onClick, setPage (useState), and useEffect. The page number lived in React state, so the component had to run in the browser.

The key insight: move the page number to the URL

What if the page number didn't live in useState at all? What if it lived in the URL?

/posts?page=1  →  user clicks "Next"  →  /posts?page=2

Now "clicking Next" is just a link — a regular <a> tag (or Next.js <Link>). No onClick handler needed. No useState. No useEffect. The browser navigates to a new URL, and the server renders the page with the new page number.

Step 12 approachStep 20 approach
Page number in useStatePage number in URL (?page=2)
onClicksetPage(2)<Link href="/posts?page=2">
useEffect watches page and fetchesServer reads searchParams and queries directly
Requires Client ComponentWorks as Server Component
JS runs in the browserHTML generated on the server

The "interactivity" we thought we needed was actually an artifact of storing state in the browser. By moving the state to the URL, the interactivity becomes navigation — which the browser handles natively, no JavaScript required.

This is a general pattern: Before reaching for useState, ask yourself — could this state live in the URL instead? Pagination, filters, search queries, sort order, active tabs — all of these are good candidates for URL-based state. The result is Server Component-compatible, SEO-friendly, shareable, and bookmarkable.


use() Hook vs. await in Server Components

↑ Index

We know useEffect has problems for data fetching. React 19 offers two alternatives — let's understand both before choosing one.

Option A: The use() Hook (Client Component + Suspense)

You learned about use() in the React tutorial. It consumes a promise during render, suspending the component until the promise resolves. The pattern looks like this:

// Server Component — starts the fetch, passes the promise
async function PostsPage() {
  const postsPromise = fetchPosts(); // no await — returns a promise
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <PostList postsPromise={postsPromise} />
    </Suspense>
  );
}
// Client Component — reads the promise with use()
"use client";
import { use } from "react";

function PostList({ postsPromise }) {
  const posts = use(postsPromise); // suspends until resolved
  return posts.map(p => <div key={p.id}>{p.title}</div>);
}

This works and avoids the double-render problem. But notice: PostList is still a Client Component — its code ships to the browser as JavaScript, and you'd use this pattern when the component needs client-side interactivity (event handlers, useState, etc.) alongside the fetched data.

Option B: await in a Server Component

If the component doesn't need interactivity — it just renders data — there's a simpler option. Server Components can be async and use await directly:

// Server Component — fetches and renders, no client JS needed
export default async function PostList() {
  const posts = await prisma.post.findMany(); // await directly
  return posts.map(p => <div key={p.id}>{p.title}</div>);
}

No use(), no Client Component, no JavaScript shipped to the browser. The HTML is generated on the server and sent to the client.

Which Do We Need Here?

Our PostList renders posts — it doesn't have click handlers, doesn't manage state, and doesn't need hooks. There's no reason for it to be a Client Component.

We'll use Option B: await in a Server Component. It's simpler, sends less JavaScript to the browser, and the database query runs directly on the server without any extra API layer.

We still get Suspense — the parent page wraps PostList in a Suspense boundary so the page shell streams immediately while the database query runs.

PatternWhen to use
await in Server ComponentData is needed for rendering, no client interactivity required (our case)
use(promise) in Client ComponentServer Component starts fetch, client needs the data + interactivity (event handlers, hooks)
useEffect in Client ComponentSide effects: subscriptions, WebSockets, timers, analytics

Rule of thumb: If the component doesn't need useState, useEffect, or event handlers — make it a Server Component and use await.

Note: We are not using the use() hook in this refactor. The code below uses await in Server Components. The use() explanation above is to help you understand the full picture and make an informed architectural choice.


Step-by-Step Refactor

↑ Index

Step 1: Create a Server Component for the Post List

Create a new file app/posts/PostList.tsx:

import Link from "next/link";
import prisma from "@/lib/prisma";

const POSTS_PER_PAGE = 5;

type Props = {
  page: number;
};

export default async function PostList({ page }: Props) {
  const skip = (page - 1) * POSTS_PER_PAGE;

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

  const totalPages = Math.ceil(total / POSTS_PER_PAGE);

  return (
    <>
      <p className="mb-8 text-gray-600">
        {total} posts &middot; Page {page} of {totalPages}
      </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">
        {page > 1 ? (
          <Link
            href={`/posts?page=${page - 1}`}
            className="rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
          >
            &larr; Previous
          </Link>
        ) : (
          <span />
        )}

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

        {page < totalPages ? (
          <Link
            href={`/posts?page=${page + 1}`}
            className="rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
          >
            Next &rarr;
          </Link>
        ) : (
          <span />
        )}
      </div>
    </>
  );
}

Key changes from the original:

  • No "use client" — this is a Server Component
  • async function — Server Components can be async
  • Direct Prisma query — no fetch() or API route needed
  • Link with href — pagination uses URL navigation instead of useState + onClick
  • No loading stateSuspense handles this (see next step)

Step 2: Refactor the Page to Use searchParams and Suspense

Replace app/posts/page.tsx entirely:

import { Suspense } from "react";
import PostList from "./PostList";

type Props = {
  searchParams: Promise<{ page?: string }>;
};

export default async function PostsPage({ searchParams }: Props) {
  const { page: pageParam } = await searchParams;
  const page = Math.max(1, Number(pageParam) || 1);

  return (
    <>
      <h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>

      <Suspense
        key={page}
        fallback={<p className="text-gray-500">Loading...</p>}
      >
        <PostList page={page} />
      </Suspense>
    </>
  );
}

Let's break this down:

searchParams — URL-Based Pagination

In Next.js 16, searchParams is a promise that resolves to the query string parameters. When a user visits /posts?page=2, searchParams resolves to { page: "2" }.

const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
  • Number(pageParam) || 1 — converts to number, defaults to 1 if missing or invalid
  • Math.max(1, ...) — ensures page is never less than 1

This replaces const [page, setPage] = useState(1) — the page number now lives in the URL.

Suspense with key

<Suspense key={page} fallback={<p className="text-gray-500">Loading...</p>}>
  <PostList page={page} />
</Suspense>

The key={page} is important. When the page number changes (user clicks Next), React sees a new key and unmounts the old PostList, triggering the Suspense fallback again while the new data loads. Without key, React would try to update the existing component in place and the fallback wouldn't re-appear.

Step 3: Remove the API Route GET Handler (Optional)

The GET handler in app/api/posts/route.ts is no longer used by the posts page. You can either:

  • Remove the GET function if nothing else calls it
  • Keep it if you plan to expose a public API for external clients

The POST handler (if present) is also unused — as noted in Step 11, Server Actions handle post creation.


Understanding Suspense and Streaming

↑ Index

When Next.js renders this page, here's what happens:

1. Request: GET /posts?page=2

2. Server renders PostsPage:
   ├── <h2>All Posts</h2>         ← sent immediately
   └── <Suspense>
       ├── fallback: "Loading..." ← sent immediately
       └── <PostList page={2} />  ← Prisma query starts

3. Prisma query completes:
   └── PostList HTML replaces "Loading..." via streaming

The browser receives the page shell (heading + "Loading...") immediately. When the database query finishes, Next.js streams the actual post list HTML to replace the fallback — without a full page reload or client-side JavaScript fetch.

This is fundamentally different from useEffect:

useEffect (Before)Suspense (After)
Initial HTMLEmpty page (no content)Page shell with "Loading..."
When data loadsJavaScript fetches → re-rendersServer streams HTML → replaces fallback
JavaScript requiredYes — nothing renders without JSNo — works without JS (progressive enhancement)
SEOSearch engines see empty pageSearch engines see full content

Before vs. After: Architecture Comparison

↑ Index

Before (Step 12): Client Component + useEffect

Browser                              Server
──────                               ──────
1. Request /posts
2. Receive empty HTML + JS bundle
3. React hydrates
4. useEffect fires
5. fetch(/api/posts?take=5&skip=0) ──→ API Route → Prisma → DB
6. Receive JSON ◄────────────────────
7. setState → re-render with data

Files involved: app/posts/page.tsx (client) → app/api/posts/route.ts (API) → lib/prisma.ts

After (Step 20): Server Component + Suspense

Browser                              Server
──────                               ──────
1. Request /posts?page=1
2. Receive page shell HTML immediately
3. Server queries Prisma → DB
4. Receive streamed post list HTML

Files involved: app/posts/page.tsx (server) → app/posts/PostList.tsx (server) → lib/prisma.ts

The API route is eliminated. The number of network round-trips drops from two (HTML + fetch) to one (streamed HTML).


Performance Benefits

↑ Index

MetricBefore (useEffect)After (Server Component)
Time to First Contentful PaintSlow — waits for JS to load and executeFast — HTML streams from server immediately
JavaScript bundleEntire page component + React hooks shipped to browserZero JS for the post list (Server Component)
Network requests2 (page HTML + API fetch)1 (streamed HTML)
SEOPoor — content invisible to crawlersFull — content is in the HTML
Loading state codeManual (useState, setLoading)Declarative (Suspense fallback)
Database accessIndirect (client → API route → Prisma)Direct (Server Component → Prisma)
URL shareabilityPage number lost on refresh (useState)Page number in URL (?page=2) — shareable and bookmarkable

Pagination UX Improvement

The URL-based pagination has a practical benefit: users can share links to specific pages. With useState, refreshing the browser always reset to page 1. With searchParams, /posts?page=3 always shows page 3.


Summary & Key Takeaways

↑ Index

ConceptDetails
Why refactoruseEffect fetching causes double renders, poor SEO, extra API layer, and manual loading states
Server ComponentsRemove "use client" — query the database directly with await, send HTML to the browser
searchParamsURL-based pagination (?page=2) replaces useState — shareable, bookmarkable, SEO-friendly
SuspenseWraps the data-fetching component; shows a fallback while the server query runs; streams HTML when done
key on SuspenseChanging key={page} forces React to re-trigger the fallback on page changes
use() vs awaituse() is for Client Components consuming server-created promises; await in Server Components is simpler when no client interactivity is needed
Performance gainsSmaller JS bundle, fewer network requests, faster first paint, full SEO
API routeNo longer needed for the posts page — Server Components access Prisma directly

Cross-reference: For a deeper understanding of the use() hook, Suspense, and how they compare to useEffect, see the React tutorial sections on "The use() Hook" and "use() a Promise".