← Back to DevBytes

Server-Side Rendering with Drizzle ORM: SSR, SSG, ISR

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles