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
- SEO: Pre-rendered HTML is crawlable by search engines, unlike client-only React apps.
- Performance: SSG and ISR serve cached HTML from a CDN edge, reducing TTFB dramatically.
- Freshness control: SSR gives real-time data; ISR gives you a tunable middle ground.
- Type safety: Prisma generates TypeScript types from your schema, eliminating runtime query mismatches.
- Security: Database queries never reach the browser; only serialized props do.
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
- Use a Prisma singleton: Never instantiate
PrismaClientper request in development; reuse a global instance to prevent connection exhaustion. - Select only needed fields: Use Prisma's
selectoption to reduce payload size and serialization cost, especially for SSG where data is baked into the bundle. - Serialize carefully: Strip Date and Decimal objects before passing to props in the Pages Router. Consider a helper like
superjsonfor complex types. - Choose the right strategy per route: Marketing and docs → SSG; blogs and catalogs → ISR; dashboards and personalized pages → SSR.
- Handle not-found and errors: Return
notFound: truewhen Prisma returns null, and wrap queries in try/catch for resilience. - Index your database: Add indexes on columns used in
where,orderBy, andfindUniqueclauses to keep SSR response times low. - Use connection pooling in production: For serverless deployments, configure a connection pooler like PgBouncer or use Prisma Accelerate to avoid connection limits.
- Cache aggressively at the edge: Combine ISR with CDN caching headers for maximum performance on read-heavy routes.
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.