Server-Side Rendering with Drizzle ORM: SSR, SSG, ISR
Modern web applications demand fast initial page loads, SEO-friendly content, and dynamic data. Combining Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) with Drizzle ORM gives you a type-safe, lightweight way to query your database at the right time and in the right place. This tutorial walks through what each rendering strategy means, why Drizzle fits naturally into each, and how to implement them in a Next.js App Router project.
What Is Drizzle ORM?
Drizzle ORM is a TypeScript-first ORM designed to be lightweight, SQL-like, and edge-compatible. Unlike heavier ORMs, Drizzle ships a small bundle size, supports raw SQL when you need it, and gives you fully inferred types from your schema. These properties make it especially well-suited for serverless and edge runtimes where SSR, SSG, and ISR often execute.
Why Rendering Strategy Matters
- SSR renders the page on every request — best for highly personalized or frequently changing data.
- SSG renders the page once at build time — best for content that rarely changes and where maximum performance is required.
- ISR renders the page once, then revalidates on a schedule or on-demand — best for content that updates periodically but doesn't need real-time freshness.
Choosing the wrong strategy leads to either stale data or unnecessary database load. Drizzle helps because its queries are explicit, fast, and easy to run inside any of these rendering contexts.
Setting Up Drizzle ORM
First, install Drizzle and a database driver. This example uses PostgreSQL with the postgres-js driver.
npm install drizzle-orm postgres
npm install -D drizzle-kit @types/node
Create a schema file that describes your tables. Drizzle infers TypeScript types directly from this definition.
// db/schema.ts
import { pgTable, serial, text, timestamp, integer } from "drizzle-orm/pg-core";
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content").notNull(),
views: integer("views").default(0),
publishedAt: timestamp("published_at").defaultNow(),
});
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Next, create the database client. Use a singleton pattern to avoid creating multiple connections in development.
// db/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, { max: 1 });
export const db = drizzle(client, { schema });
SSR: Rendering on Every Request
With SSR, the server queries the database and renders HTML for each incoming request. In the Next.js App Router, server components are dynamically rendered by default when they read request-specific data or use functions like cookies() or headers().
Implementing SSR with Drizzle
// app/posts/page.tsx
import { db } from "@/db/client";
import { posts } from "@/db/schema";
import { desc } from "drizzle-orm";
export const dynamic = "force-dynamic";
export default async function PostsPage() {
const allPosts = await db
.select()
.from(posts)
.orderBy(desc(posts.publishedAt));
return (
<main>
<h1>Latest Posts</h1>
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
);
}
The force-dynamic export tells Next.js to always render this page on the server at request time. The Drizzle query runs on every request, so users always see fresh data. This is ideal for dashboards, admin panels, or pages showing user-specific content.
SSG: Rendering at Build Time
SSG renders the page once during the build process. The HTML is cached and served as a static asset. This produces the fastest possible response times but means the data is frozen at build time.
Implementing SSG with Drizzle
// app/posts/[slug]/page.tsx
import { db } from "@/db/client";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
const allPosts = await db.select({ slug: posts.slug }).from(posts);
return allPosts.map((post) => ({
slug: post.slug,
}));
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.slug, params.slug));
if (!post) return notFound();
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt?.toISOString()}</time>
<p>{post.content}</p>
</article>
);
}
The generateStaticParams function runs at build time, querying Drizzle for all slugs. Next.js then pre-renders each page. The resulting HTML is served from a CDN with no database calls at runtime. This is perfect for blog posts, documentation, and marketing pages.
ISR: Incremental Static Regeneration
ISR gives you the performance of SSG with the freshness of SSR. The page is generated once, served statically, and then regenerated in the background after a specified time interval or when explicitly triggered.
Time-Based Revalidation
// app/products/page.tsx
import { db } from "@/db/client";
import { products } from "@/db/schema";
export const revalidate = 60; // revalidate every 60 seconds
export default async function ProductsPage() {
const allProducts = await db.select().from(products);
return (
<main>
<h1>Products</h1>
{allProducts.map((product) => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>{product.price}</p>
</div>
))}
</main>
);
}
With revalidate = 60, the first request after 60 seconds triggers a background regeneration. Users continue seeing the cached version until the new one is ready. This balances freshness with performance.
On-Demand Revalidation
For more control, use on-demand revalidation via an API route. This is useful when content changes are event-driven, such as when an editor publishes a new post.
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (body.path) {
revalidatePath(body.path);
return NextResponse.json({ revalidated: true, path: body.path });
}
return NextResponse.json({ error: "Missing path" }, { status: 400 });
}
You can call this endpoint from a CMS webhook or after a database mutation to instantly refresh the static page.
Combining ISR with Mutations
// app/admin/posts/actions.ts
"use server";
import { db } from "@/db/client";
import { posts } from "@/db/schema";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
const content = formData.get("content") as string;
await db.insert(posts).values({ title, slug, content });
revalidatePath("/posts");
revalidatePath(`/posts/${slug}`);
}
After inserting a new post with Drizzle, calling revalidatePath regenerates the affected pages immediately, keeping static content in sync with your database.
Best Practices
- Use connection pooling. In serverless environments, each function instance may create its own connection. Use a pooler like PgBouncer or Neon's connection pooler to avoid exhausting connections.
- Keep the Drizzle client singleton. Avoid instantiating the client inside component functions. Import it from a shared module.
- Choose the right strategy per route. Not every page needs SSR. Default to SSG or ISR and escalate to SSR only when necessary.
- Select only what you need. Use Drizzle's
select({ ... })to project only the columns you render, reducing payload and memory usage. - Handle errors gracefully. Wrap database calls in try/catch and provide fallback UI, especially for SSR routes where a database outage would break the page.
- Validate with Zod at boundaries. Combine Drizzle with Zod for input validation on server actions and API routes.
- Monitor query performance. Use Drizzle Studio during development and log slow queries in production to catch N+1 problems early.
Conclusion
Drizzle ORM pairs naturally with SSR, SSG, and ISR because its queries are explicit, fast, and fully typed. By matching each route to the appropriate rendering strategy — SSR for dynamic, personalized pages; SSG for stable, high-traffic content; and ISR for pages that need periodic updates — you can deliver excellent performance without sacrificing data freshness. Start with SSG where possible, layer in ISR for content that changes on a schedule, and reserve SSR for pages where every request must reflect the latest state. With Drizzle's lightweight footprint and TypeScript-first design, you get a rendering pipeline that is both developer-friendly and production-ready.