Search and Pagination
by Tomas Trescak· Databases

0 / 1930 XP
The external project has not been reviewed yet
Please follow instructions below to submit your project

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 Post model 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 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

Section 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:
    • page from query params.
    • skip and take for 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: where filters 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?
    • IntersectionObserver triggers loadPosts when 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=draft with edit buttons.
  • Infinite scroll: Less common, but possible.

Conclusion

We’ve built a killer blog app! Key takeaways:

  • Paging: skip and take for structure—perfect for control.
  • Search: Filter with where, pair with paging or scroll—users find what they need.
  • Infinite Scroll: Cursors and 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!

Maggie

Discuss with Maggie
Use the power of generative AI to interact with course content

Maggie is a generative AI that can help you understand the course content better. You can ask her questions about the lecture, and she will try to answer them. You can also see the questions asked by other students and her responses.

Discuss with Others
Ask questions, share your thoughts, and discuss with other learners

Join the discussion to ask questions, share your thoughts, and discuss with other learners
Setup
React Fundamentals
10 points
Next.js
10 points
Advanced React
Databases
10 points
React Hooks
Authentication and Authorisation
10 points
APIs
CI/CD and DevOps
Testing React
Advanced Topics