ContentDetailed description of the section content.
Lecture: Implementing Paging, Search, and Infinite Scroll in a Blogging Application with Next.js, Prisma, and PostgreSQL
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!
Motivation
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!
Section 1: Setting Up the Foundation
We’ll start with the database and Next.js setup.
Step 1: Prisma Schema
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())
}
- What’s this? A
Postmodel with ID, title, content, tags, and date. Prisma connects it to PostgreSQL.
Step 2: Prisma Setup (if not already done)
Add Prisma:
pnpm install prisma @prisma/client
pnpx prisma initSet DATABASE_URL in .env:
DATABASE_URL="postgresql://user:password@localhost:5432/blogdb"Migrate:
npx prisma migrate dev --name initSeed 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.tsSection 2: Paging with Next.js API Routes
Paging shows 10 posts per page. Users hit /posts?page=2 for posts 11–20.
Step 3: API Route
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) });
}
- How it works:
pagefrom query params.skipandtakefor pagination.- Returns posts, total count, and page count.
Step 4: Client-Side Paging
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>
);
}
- What’s happening? Fetches posts for the current page, updates with “Next”/“Previous” buttons.
Test: Visit /posts?page=2—see posts 11–20!
Section 3: Adding Search
Search filters posts by title or tag, still paged.
Step 5: Update API Route
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) });
}
- New stuff:
wherefilters by search term.
Step 6: Client-Side Search
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>
);
}- What’s new? Search input resets to page 1 and filters posts.
Test: Type “travel” → see 10 travel posts per page (25 total, 3 pages).
Section 4: Infinite Scroll
Infinite scroll loads posts as users scroll.
Step 7: Infinite Scroll API
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 });
}- How it works: Cursor-based pagination for continuous loading.
Step 8: Client-Side Infinite Scroll
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>
);
}- What’s happening?
IntersectionObservertriggersloadPostswhen the “Loading more…” div is visible.- Posts append as you scroll.
- Search resets the list.
Test: Visit /posts/infinite, scroll—posts load 10 at a time. Search “food”—scroll through 25 food posts.
Section 5: Admin Use Case
Admins can use these too! Same API routes work:
- Paging:
/admin/posts?page=2(customize frontend). - Search:
/admin/posts?search=draftwith edit buttons. - Infinite scroll: Less common, but possible.
Conclusion
We’ve built a killer blog app! Key takeaways:
- Paging:
skipandtakefor structure—perfect for control. - Search: Filter with
where, pair with paging or scroll—users find what they need. - Infinite Scroll: Cursors and
IntersectionObserverfor 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!