Let’s build paging, search, and infinite scroll for our blog app using Next.js with the App Router, Prisma, and PostgreSQL. We’ll cover both the backend (API routes) and the frontend (React components), step-by-step, using our blogging app example. Buckle up—this will be hands-on and practical!
Picture this: in our blog app, users read posts, and admins manage them. With tons of posts, loading everything at once is a no-go—it’s slow and frustrating. Paging splits posts into pages, search finds specific ones fast, and infinite scroll keeps users scrolling seamlessly. These are core web dev skills, and they’ll make your projects stand out.
Our challenge? Users want to browse by tag or date, and admins need to manage posts efficiently. We’ll use Next.js for a full-stack solution, Prisma for database magic, and PostgreSQL for storage. Think all posts should load at once? Think again—that’s a performance killer we’ll avoid today!
We’ll start with the database and Next.js setup.
In prisma/schema.prisma
(if not already done):
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
tags String[]
createdAt DateTime @default(now())
}
Post
model with ID, title, content, tags, and date. Prisma connects it to PostgreSQL.Add Prisma:
pnpm install prisma @prisma/client
pnpx prisma init
Set DATABASE_URL
in .env
:
DATABASE_URL="postgresql://user:password@localhost:5432/blogdb"
Migrate:
npx prisma migrate dev --name init
Seed data (prisma/seed.ts
):
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.post.createMany({
data: Array.from({ length: 50 }, (_, i) => ({
title: `Post ${i + 1}`,
content: `This is post ${i + 1} content.`,
tags: i % 2 === 0 ? ['travel'] : ['food'],
createdAt: new Date(2024, Math.floor(i / 10), 1),
})),
});
console.log('Seeded 50 posts!');
}
main().then(() => prisma.$disconnect()).catch(console.error);
Run:
pnpx tsx prisma/seed.ts
Paging shows 10 posts per page. Users hit /posts?page=2
for posts 11–20.
In app/api/posts/route.ts
:
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = 10;
const skip = (page - 1) * limit;
const posts = await prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
});
const total = await prisma.post.count();
return NextResponse.json({ posts, total, pages: Math.ceil(total / limit) });
}
page
from query params.skip
and take
for pagination.In app/posts/page.tsx
:
'use client';
import { useState, useEffect } from 'react';
type Post = { id: number; title: string; content: string; tags: string[]; createdAt: string };
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetch(`/api/posts?page=${page}`)
.then(res => res.json())
.then(data => {
setPosts(data.posts);
setTotalPages(data.pages);
});
}, [page]);
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title} - {post.tags.join(', ')}</li>
))}
</ul>
<div>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</button>
<span> Page {page} of {totalPages} </span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
Next
</button>
</div>
</div>
);
}
Test: Visit /posts?page=2
—see posts 11–20!
Search filters posts by title or tag, still paged.
In app/api/posts/route.ts
:
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const search = searchParams.get('search') || '';
const limit = 10;
const skip = (page - 1) * limit;
const where = search
? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ tags: { has: search } },
],
}
: {};
const posts = await prisma.post.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
});
const total = await prisma.post.count({ where });
return NextResponse.json({ posts, total, pages: Math.ceil(total / limit) });
}
where
filters by search term.Update app/posts/page.tsx
:
'use client';
import { useState, useEffect } from 'react';
type Post = { id: number; title: string; content: string; tags: string[]; createdAt: string };
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
const url = `/api/posts?page=${page}${search ? `&search=${search}` : ''}`;
fetch(url)
.then(res => res.json())
.then(data => {
setPosts(data.posts);
setTotalPages(data.pages);
});
}, [page, search]);
return (
<div>
<h1>Blog Posts</h1>
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
placeholder="Search by title or tag"
/>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title} - {post.tags.join(', ')}</li>
))}
</ul>
<div>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</button>
<span> Page {page} of {totalPages} </span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
Next
</button>
</div>
</div>
);
}
Test: Type “travel” → see 10 travel posts per page (25 total, 3 pages).
Infinite scroll loads posts as users scroll.
In app/api/posts/infinite/route.ts
:
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get('cursor') ? parseInt(searchParams.get('cursor')!) : undefined;
const search = searchParams.get('search') || '';
const limit = 10;
const where = search
? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ tags: { has: search } },
],
}
: {};
const posts = await prisma.post.findMany({
where,
take: limit,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'desc' },
});
const nextCursor = posts.length === limit ? posts[posts.length - 1].id : null;
return NextResponse.json({ posts, nextCursor });
}
In app/posts/infinite/page.tsx
:
'use client';
import { useState, useEffect, useRef } from 'react';
type Post = { id: number; title: string; content: string; tags: string[]; createdAt: string };
export default function InfinitePostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [search, setSearch] = useState('');
const [cursor, setCursor] = useState<number | null>(null);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadPosts = async () => {
const url = `/api/posts/infinite${cursor ? `?cursor=${cursor}` : ''}${search ? `${cursor ? '&' : '?'}search=${search}` : ''}`;
const res = await fetch(url);
const data = await res.json();
setPosts(prev => [...prev, ...data.posts]);
setCursor(data.nextCursor);
setHasMore(!!data.nextCursor);
};
useEffect(() => {
setPosts([]);
setCursor(null);
setHasMore(true);
loadPosts();
}, [search]);
useEffect(() => {
if (!hasMore) return;
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadPosts();
}, { threshold: 1.0 });
if (loadMoreRef.current) observerRef.current.observe(loadMoreRef.current);
return () => observerRef.current?.disconnect();
}, [cursor, hasMore]);
return (
<div>
<h1>Infinite Blog Posts</h1>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by title or tag"
/>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title} - {post.tags.join(', ')}</li>
))}
</ul>
{hasMore && <div ref={loadMoreRef}>Loading more...</div>}
</div>
);
}
IntersectionObserver
triggers loadPosts
when the “Loading more…” div is visible.Test: Visit /posts/infinite
, scroll—posts load 10 at a time. Search “food”—scroll through 25 food posts.
Admins can use these too! Same API routes work:
/admin/posts?page=2
(customize frontend)./admin/posts?search=draft
with edit buttons.We’ve built a killer blog app! Key takeaways:
skip
and take
for structure—perfect for control.where
, pair with paging or scroll—users find what they need.IntersectionObserver
for seamless loading—engaging!Pitfalls? Don’t forget orderBy
(random order sucks), test search edge cases, and ensure infinite scroll stops when data ends. You’ve got a full-stack solution—go make your app awesome!