Refactoring Pagination: From useEffect to Server Components & Suspense
Step 20 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
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
useEffectfetching with a direct Prisma query in a Server Component - Replace
useStatepagination with URL-based pagination usingsearchParams - Wrap the data-dependent UI in
Suspensefor streaming
By the end, the posts page will load faster, be fully SEO-friendly, and have simpler code.
Table of Contents
- The Problem with useEffect Fetching
- The Solution: Server Components + URL Pagination
- But Wait — Didn't We Need Interactivity?
- use() Hook vs. await in Server Components
- Step-by-Step Refactor
- Understanding Suspense and Streaming
- Before vs. After: Architecture Comparison
- Performance Benefits
- Summary & Key Takeaways
The Problem with useEffect Fetching
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:
| Problem | Why it matters |
|---|---|
| Double render | The component renders once empty, then re-renders after data arrives |
| No SEO | Search engines see an empty page — the content loads only after JavaScript runs |
| Extra API layer | The page fetches from /api/posts, which calls Prisma — an unnecessary round-trip since both run on the same server |
| Manual loading state | You must manage loading, setLoading(true), setLoading(false) manually |
| Client bundle size | The entire component ships to the browser as JavaScript |
If you've read the React tutorial on
use()vsuseEffect, 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
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:
- Remove
"use client"— make the page a Server Component - Query Prisma directly — no API route needed
- Use
searchParamsfor pagination —?page=2instead ofuseState - Use
Suspense— React streams the page shell immediately and fills in data when ready
But Wait — Didn't We Need Interactivity?
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 approach | Step 20 approach |
|---|---|
Page number in useState | Page number in URL (?page=2) |
onClick → setPage(2) | <Link href="/posts?page=2"> |
useEffect watches page and fetches | Server reads searchParams and queries directly |
| Requires Client Component | Works as Server Component |
| JS runs in the browser | HTML 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
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.
| Pattern | When to use |
|---|---|
await in Server Component | Data is needed for rendering, no client interactivity required (our case) |
use(promise) in Client Component | Server Component starts fetch, client needs the data + interactivity (event handlers, hooks) |
useEffect in Client Component | Side 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 useawait.
Note: We are not using the
use()hook in this refactor. The code below usesawaitin Server Components. Theuse()explanation above is to help you understand the full picture and make an informed architectural choice.
Step-by-Step Refactor
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 · 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"} ·{" "}
{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"
>
← 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 →
</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 Linkwithhref— pagination uses URL navigation instead ofuseState+onClick- No loading state —
Suspensehandles 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 invalidMath.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
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 HTML | Empty page (no content) | Page shell with "Loading..." |
| When data loads | JavaScript fetches → re-renders | Server streams HTML → replaces fallback |
| JavaScript required | Yes — nothing renders without JS | No — works without JS (progressive enhancement) |
| SEO | Search engines see empty page | Search engines see full content |
Before vs. After: Architecture Comparison
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
| Metric | Before (useEffect) | After (Server Component) |
|---|---|---|
| Time to First Contentful Paint | Slow — waits for JS to load and execute | Fast — HTML streams from server immediately |
| JavaScript bundle | Entire page component + React hooks shipped to browser | Zero JS for the post list (Server Component) |
| Network requests | 2 (page HTML + API fetch) | 1 (streamed HTML) |
| SEO | Poor — content invisible to crawlers | Full — content is in the HTML |
| Loading state code | Manual (useState, setLoading) | Declarative (Suspense fallback) |
| Database access | Indirect (client → API route → Prisma) | Direct (Server Component → Prisma) |
| URL shareability | Page 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
| Concept | Details |
|---|---|
| Why refactor | useEffect fetching causes double renders, poor SEO, extra API layer, and manual loading states |
| Server Components | Remove "use client" — query the database directly with await, send HTML to the browser |
searchParams | URL-based pagination (?page=2) replaces useState — shareable, bookmarkable, SEO-friendly |
Suspense | Wraps the data-fetching component; shows a fallback while the server query runs; streams HTML when done |
key on Suspense | Changing key={page} forces React to re-trigger the fallback on page changes |
use() vs await | use() is for Client Components consuming server-created promises; await in Server Components is simpler when no client interactivity is needed |
| Performance gains | Smaller JS bundle, fewer network requests, faster first paint, full SEO |
| API route | No 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 touseEffect, see the React tutorial sections on "Theuse()Hook" and "use()a Promise".