← Back to DevBytes

Server-Side Rendering with Prisma: SSR, SSG, ISR

Server-Side Rendering with Prisma: SSR, SSG, ISR

Modern web applications demand fast initial page loads, SEO-friendly content, and fresh data when it matters. Combining Prisma — a type-safe ORM for Node.js — with Next.js rendering strategies gives you a powerful toolkit to balance performance, freshness, and developer experience. This tutorial walks through three core rendering patterns: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR), all powered by Prisma.

What Is Server-Side Rendering with Prisma?

Server-Side Rendering refers to generating HTML on the server for each request (SSR), at build time (SSG), or on a scheduled interval after build (ISR). Prisma sits between your Next.js server code and your database, providing a fully typed query layer. Because Prisma queries run only on the server, it pairs naturally with Next.js server components, API routes, and data-fetching functions like getServerSideProps, getStaticProps, and the App Router's async server components.

Why It Matters

Project Setup

Start by initializing a Next.js project and installing Prisma. The following commands scaffold the app and configure Prisma with SQLite for demonstration purposes — swap the datasource for PostgreSQL or MySQL in production.

# Create a Next.js app
npx create-next-app@latest prisma-ssr-demo
cd prisma-ssr-demo

# Install Prisma
npm install prisma --save-dev
npm install @prisma/client

# Initialize Prisma
npx prisma init --datasource-provider sqlite

Define a simple schema with a Post model so we have data to render across all three strategies.

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Create a singleton Prisma client to avoid exhausting database connections during hot reloads in development. This is a critical best practice.

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Seed the database with sample posts so the rendering examples have content to display.

// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function main() {
  await prisma.post.createMany({
    data: [
      { title: 'SSR Basics', content: 'Rendered on every request.', published: true },
      { title: 'SSG Power', content: 'Rendered once at build time.', published: true },
      { title: 'ISR Magic', content: 'Revalidated on a schedule.', published: true },
    ],
  })
}

main()
  .then(() => prisma.$disconnect())
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })

Add the seed script to your package.json and run it.

{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
npx prisma db push
npx prisma db seed

SSR: Server-Side Rendering on Every Request

SSR generates HTML on the server for each incoming request. Use it when data changes frequently and must always be current — dashboards, personalized feeds, or admin panels. In the Pages Router, this is done with getServerSideProps.

// pages/posts/index.tsx
import type { GetServerSideProps, NextPage } from 'next'
import { prisma } from '../../lib/prisma'

type Post = { id: number; title: string; content: string }

type Props = { posts: Post[]; generatedAt: string }

const PostsPage: NextPage<Props> = ({ posts, generatedAt }) => {
  return (
    <main>
      <h1>All Posts (SSR)</h1>
      <p>Generated at: {generatedAt}</p>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            <strong>{p.title}</strong> — {p.content}
          </li>
        ))}
      </ul>
    </main>
  )
}

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })

  return {
    props: {
      posts: JSON.parse(JSON.stringify(posts)),
      generatedAt: new Date().toISOString(),
    },
  }
}

export default PostsPage

Note the JSON.parse(JSON.stringify(posts)) step. Prisma returns Date objects that cannot be serialized directly into Next.js props. This conversion strips non-serializable fields. In the App Router, you can avoid this by using prisma.post.findMany() directly inside an async server component, since RSC handles serialization differently.

SSG: Static Site Generation at Build Time

SSG pre-renders pages once during the build. It is ideal for content that rarely changes: blog posts, marketing pages, documentation. The page is served as static HTML from a CDN, giving you the fastest possible TTFB.

// pages/posts/[id].tsx
import type { GetStaticPaths, GetStaticProps, NextPage } from 'next'
import { prisma } from '../../lib/prisma'

type Post = { id: number; title: string; content: string }

type Props = { post: Post }

const PostPage: NextPage<Props> = ({ post }) => {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await prisma.post.findMany({ where: { published: true } })
  const paths = posts.map((p) => ({ params: { id: String(p.id) } }))

  return { paths, fallback: false }
}

export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
  const id = Number(ctx.params?.id)
  const post = await prisma.post.findUnique({ where: { id } })

  if (!post) return { notFound: true }

  return {
    props: { post: JSON.parse(JSON.stringify(post)) },
  }
}

export default PostPage

Here getStaticPaths queries Prisma at build time to enumerate every published post, and getStaticProps fetches the full record for each. Setting fallback: false means any path not returned by getStaticPaths returns a 404. For large datasets, use fallback: 'blocking' or fallback: true to generate pages on demand.

ISR: Incremental Static Regeneration

ISR combines the speed of SSG with the freshness of SSR. Pages are generated statically, then revalidated in the background at a configurable interval. The first request after the revalidation window triggers a rebuild; subsequent requests receive the freshly generated HTML. This is perfect for content that updates periodically but does not need to be real-time.

// pages/blog/index.tsx
import type { GetStaticProps, NextPage } from 'next'
import { prisma } from '../../lib/prisma'

type Post = { id: number; title: string; content: string }
type Props = { posts: Post[]; generatedAt: string }

const BlogIndex: NextPage<Props> = ({ posts, generatedAt }) => {
  return (
    <main>
      <h1>Blog (ISR — revalidates every 60s)</h1>
      <p>Last generated: {generatedAt}</p>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </main>
  )
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })

  return {
    props: {
      posts: JSON.parse(JSON.stringify(posts)),
      generatedAt: new Date().toISOString(),
    },
    revalidate: 60, // seconds
  }
}

export default BlogIndex

You can also trigger on-demand revalidation via an API route, which is useful when content is updated through a CMS and you want the cache busted immediately rather than waiting for the interval.

// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const token = req.headers.authorization
  if (token !== `Bearer ${process.env.REVALIDATE_TOKEN}`) {
    return res.status(401).json({ message: 'Invalid token' })
  }

  try {
    await res.revalidate('/blog')
    return res.json({ revalidated: true })
  } catch (err) {
    return res.status(500).send('Error revalidating')
  }
}

Using the App Router

With the App Router, the distinctions between SSR, SSG, and ISR are expressed through route segment config and dynamic functions. Prisma queries run directly inside async server components. There is no need for getServerSideProps or getStaticProps.

// app/posts/page.tsx
import { prisma } from '@/lib/prisma'

// ISR: revalidate every 60 seconds
export const revalidate = 60

export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })

  return (
    <main>
      <h1>Posts (App Router ISR)</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </main>
  )
}

For pure SSR in the App Router, omit revalidate and export const dynamic = 'force-static'. For pure SSG, set export const dynamic = 'force-static'. For dynamic SSR with per-request data, call dynamic functions like cookies() or headers(), which automatically opt the route into dynamic rendering.

Best Practices

Conclusion

Prisma and Next.js form a natural pairing for data-driven rendering. SSR gives you real-time data, SSG gives you maximum speed for stable content, and ISR delivers a pragmatic blend of both with configurable freshness. By choosing the right strategy for each route, using a singleton Prisma client, selecting only the fields you need, and handling serialization carefully, you can build applications that are fast, SEO-friendly, type-safe, and easy to maintain. Start with SSG where possible, escalate to ISR when content updates periodically, and reserve SSR for routes where every request must reflect the latest database state.

— Ad —

Google AdSense will appear here after approval

← Back to all articles