Server-Side Rendering with Zod: SSR, SSG, ISR
Modern web frameworks like Next.js offer multiple rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). Each strategy fetches data at a different point in the request lifecycle, which means the data your server receives from APIs, databases, or CMS providers can vary in shape and reliability. This is where Zod comes in: a TypeScript-first schema validation library that lets you define the exact shape of your data and validate it at runtime, even when your types are statically correct at build time.
In this tutorial, you'll learn how to combine Zod with SSR, SSG, and ISR to build robust, type-safe server-rendered applications. We'll cover what each rendering strategy does, why validation matters, how to integrate Zod into each pattern, and the best practices you should follow in production.
What Is Zod and Why Does It Matter for Rendering?
Zod is a schema declaration and validation library. You declare a schema once, and Zod can both validate unknown input at runtime and infer the TypeScript type for use throughout your codebase. This dual purpose is especially valuable on the server, where data crosses trust boundaries — from external APIs, user input, or cached files on disk.
When you render on the server, you typically fetch data and pass it directly into React components. If that data is malformed — a missing field, a string instead of a number, a null where you expected an object — your page can crash, leak sensitive information, or render broken UI. Zod lets you catch these issues at the boundary, before the data ever reaches your component tree.
The Three Rendering Strategies
- SSR (Server-Side Rendering): The page is rendered on the server on every request. Data is always fresh, but every request incurs server cost.
- SSG (Static Site Generation): The page is rendered once at build time. The HTML is cached and served from a CDN. Data is frozen at build time.
- ISR (Incremental Static Regeneration): The page is statically generated, but revalidated in the background at a configurable interval or on-demand. Combines the speed of SSG with the freshness of SSR.
Each strategy fetches data in a different context — per request, at build time, or on a revalidation schedule. Zod protects you in all three by ensuring the data you receive matches the contract your components expect.
Setting Up the Project
Let's start with a Next.js project (App Router) and install Zod. The same patterns apply to other frameworks like Remix or SvelteKit with minor adjustments.
npm create next-app@latest zod-rendering-demo
cd zod-rendering-demo
npm install zod
Create a central location for your schemas. Keeping schemas in one place makes them reusable across server components, API routes, and client components.
// lib/schemas.ts
import { z } from "zod";
export const ArticleSchema = z.object({
id: z.number(),
title: z.string().min(1),
slug: z.string().min(1),
excerpt: z.string(),
content: z.string(),
author: z.object({
name: z.string(),
avatar: z.string().url(),
}),
publishedAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export type Article = z.infer<typeof ArticleSchema>;
export const ArticleListSchema = z.array(ArticleSchema);
export type ArticleList = z.infer<typeof ArticleListSchema>;
Notice how z.infer derives the TypeScript type from the schema. You never write the type by hand, which guarantees your runtime validation and your compile-time types stay in sync.
Using Zod with SSR
With SSR, data is fetched on every request. This is the right choice for pages that must always show fresh data — dashboards, personalized feeds, or admin panels. In the Next.js App Router, server components are dynamically rendered by default when they read request-specific data.
Validating Fetched Data in a Server Component
// app/articles/page.tsx
import { ArticleListSchema, type ArticleList } from "@/lib/schemas";
async function fetchArticles(): Promise<ArticleList> {
const res = await fetch("https://api.example.com/articles", {
cache: "no-store", // always fetch fresh data for SSR
});
if (!res.ok) {
throw new Error(`Failed to fetch articles: ${res.status}`);
}
const json: unknown = await res.json();
// Validate at the boundary
const result = ArticleListSchema.safeParse(json);
if (!result.success) {
console.error("Validation failed:", result.error.flatten());
// Return a safe fallback or throw a controlled error
throw new Error("Invalid article data received from API");
}
return result.data;
}
export default async function ArticlesPage() {
const articles = await fetchArticles();
return (
<main>
<h1>Latest Articles</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<a href={`/articles/${article.slug}`}>{article.title}</a>
<span>by {article.author.name}</span>
</li>
))}
</ul>
</main>
);
}
The key pattern here is safeParse, which returns a result object instead of throwing. This lets you handle validation failures gracefully — log the error, return a fallback, or surface a user-friendly error page. Using parse instead would throw a ZodError, which you could catch in an error boundary.
Handling Validation Errors Gracefully
In production, you don't want a single malformed record to take down an entire page. Consider filtering invalid items instead of failing the whole list:
async function fetchValidArticles(): Promise<ArticleList> {
const res = await fetch("https://api.example.com/articles", {
cache: "no-store",
});
const json: unknown = await res.json();
if (!Array.isArray(json)) {
return [];
}
return json
.map((item) => ArticleListSchema.element.safeParse(item))
.filter((r): r is z.SafeParseSuccess<Article> => r.success)
.map((r) => r.data);
}
This approach is useful when you're aggregating data from multiple sources and want to be resilient to partial failures. However, be careful: silently dropping data can hide real bugs. Always log validation failures so you can investigate the root cause.
Using Zod with SSG
SSG renders pages at build time. The data is fetched once, validated, and baked into static HTML. This is ideal for content that rarely changes — blog posts, documentation, marketing pages. In Next.js, you opt into SSG by not using dynamic functions and by configuring fetch caching appropriately.
Static Generation with getStaticProps (Pages Router)
If you're using the Pages Router, SSG is done via getStaticProps:
// pages/articles/[slug].tsx
import type { GetStaticProps, GetStaticPaths } from "next";
import { ArticleSchema, type Article } from "@/lib/schemas";
interface PageProps {
article: Article;
}
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch("https://api.example.com/articles/slugs");
const slugs: unknown = await res.json();
const SlugsSchema = z.array(z.string());
const result = SlugsSchema.safeParse(slugs);
const validSlugs = result.success ? result.data : [];
return {
paths: validSlugs.map((slug) => ({ params: { slug } })),
fallback: "blocking", // generate new pages on demand
};
};
export const getStaticProps: GetStaticProps<PageProps> = async (ctx) => {
const { slug } = ctx.params as { slug: string };
const res = await fetch(`https://api.example.com/articles/${slug}`);
if (!res.ok) {
return { notFound: true };
}
const json: unknown = await res.json();
const result = ArticleSchema.safeParse(json);
if (!result.success) {
console.error(`Invalid data for slug ${slug}:`, result.error);
return { notFound: true };
}
return {
props: { article: result.data },
};
};
export default function ArticlePage({ article }: PageProps) {
return (
<article>
<h1>{article.title}</h1>
<p>By {article.author.name}</p>
<time>{new Date(article.publishedAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
);
}
With SSG, validation failures at build time are actually a good thing — they fail your CI pipeline before broken content reaches production. You can afford to be strict here because the build is a controlled environment.
Static Generation in the App Router
In the App Router, static rendering is the default. Simply fetch data in a server component without using dynamic APIs:
// app/articles/[slug]/page.tsx
import { ArticleSchema, type Article } from "@/lib/schemas";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
const res = await fetch("https://api.example.com/articles/slugs");
const slugs: unknown = await res.json();
const SlugsSchema = z.array(z.string());
const result = SlugsSchema.safeParse(slugs);
return (result.success ? result.data : []).map((slug) => ({ slug }));
}
export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
const res = await fetch(`https://api.example.com/articles/${params.slug}`);
if (!res.ok) notFound();
const json: unknown = await res.json();
const result = ArticleSchema.safeParse(json);
if (!result.success) {
console.error(`Invalid article data for ${params.slug}`, result.error);
notFound();
}
const article: Article = result.data;
return (
<article>
<h1>{article.title}</h1>
<p>By {article.author.name}</p>
</article>
);
}
Using Zod with ISR
ISR gives you the performance of static pages with the freshness of server rendering. The page is generated statically, then revalidated in the background at a specified interval. This is perfect for content that updates periodically — news sites, product catalogs, or documentation.
Time-Based Revalidation
With ISR, you set a revalidate value. After that time elapses, the next request triggers a background regeneration:
// app/products/page.tsx
import { z } from "zod";
const ProductSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().nonnegative(),
currency: z.string().length(3),
inStock: z.boolean(),
image: z.string().url(),
updatedAt: z.string().datetime(),
});
type Product = z.infer<typeof ProductSchema>;
const ProductListSchema = z.array(ProductSchema);
export const revalidate = 60; // revalidate every 60 seconds
async function fetchProducts(): Promise<Product[]> {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
const json: unknown = await res.json();
const result = ProductListSchema.safeParse(json);
if (!result.success) {
console.error("Product validation failed:", result.error.issues);
// Return empty list rather than crashing the page
return [];
}
return result.data;
}
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
<img src={p.image} alt={p.name} />
<h2>{p.name}</h2>
<p>
{p.currency} {p.price.toFixed(2)}
</p>
<p>{p.inStock ? "In stock" : "Out of stock"}</p>
</li>
))}
</ul>
</main>
);
}
With ISR, there's an important subtlety: the first request after revalidation serves the stale cached page while the new version is generated in the background. This means your users might briefly see data from the previous render. Zod ensures that even if the API changes its response shape between regenerations, you won't serve a broken page — you'll either get valid data or your fallback.
On-Demand Revalidation
For more control, you can trigger revalidation on demand using a webhook or API route. This is useful when you know exactly when your data changes — for example, when an editor publishes a new article in your CMS:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const RevalidateBodySchema = z.object({
path: z.string().startsWith("/"),
secret: z.string(),
});
export async function POST(request: NextRequest) {
const json: unknown = await request.json();
const result = RevalidateBodySchema.safeParse(json);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid request body", issues: result.error.issues },
{ status: 400 }
);
}
const { path, secret } = result.data;
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
Notice that we validate the incoming webhook payload with Zod too. Webhooks are an external input — never trust them blindly. Validating the body ensures the path is a string starting with / and that the required secret field is present before we check it against our environment variable.
Best Practices
1. Validate at Trust Boundaries
Validate data every time it crosses a trust boundary — when it comes from an external API, a database query, user input, or a file on disk. Don't validate the same data multiple times within your own code; once it's validated and typed, you can trust it downstream.
2. Use safeParse for Recoverable Errors
Prefer safeParse over parse in rendering code. parse throws, which can crash an entire page. safeParse returns a discriminated union you can handle explicitly:
const result = MySchema.safeParse(data);
if (result.success) {
// result.data is fully typed
renderPage(result.data);
} else {
// result.error is a ZodError with detailed issues
logger.error("Validation failed", result.error.issues);
renderFallback();
}
3. Be Strict at Build Time, Lenient at Runtime
During SSG builds, strict validation is desirable — a failed build is better than shipping broken pages. At runtime (SSR and ISR), consider whether a validation failure should crash the page or degrade gracefully. For ISR especially, returning cached or fallback data is often preferable to showing an error.
4. Coerce Sensibly
APIs sometimes return numbers as strings or dates as Unix timestamps. Zod provides coercion utilities, but use them deliberately:
const ArticleSchema = z.object({
id: z.coerce.number(),
title: z.string(),
publishedAt: z.coerce.date(),
viewCount: z.coerce.number().int().nonnegative().default(0),
});
Coercion is convenient, but it can mask data quality issues. If your API suddenly starts returning "abc" for a numeric field, coercion will fail loudly — which is what you want. Just be aware that z.coerce.number() will turn null into 0 and empty strings into 0, which may not be your intent.
5. Reuse Schemas Across the Stack
One of Zod's biggest advantages is schema reuse. Define a schema once and use it in your server components, API routes, form validation on the client, and even in your database layer. This creates a single source of truth for your data contracts:
// lib/schemas.ts — shared across the entire app
export const CreateArticleSchema = z.object({
title: z.string().min(1).max(200),
excerpt: z.string().max(500),
content: z.string().min(1),
tags: z.array(z.string()).max(10).default([]),
});
// app/api/articles/route.ts — server-side validation
export async function POST(request: NextRequest) {
const body: unknown = await request.json();
const result = CreateArticleSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 422 }
);
}
// result.data is typed as the inferred type
const article = await createArticle(result.data);
return NextResponse.json(article, { status: 201 });
}
// components/ArticleForm.tsx — client-side validation
"use client";
import { CreateArticleSchema } from "@/lib/schemas";
function validateForm(values: FormData) {
return CreateArticleSchema.safeParse({
title: values.get("title"),
excerpt: values.get("excerpt"),
content: values.get("content"),
tags: [],
});
}
6. Log Validation Failures with Context
When validation fails, log enough context to debug the issue without exposing sensitive data. Include the schema name, the source of the data, and the specific Zod issues:
if (!result.success) {
console.error({
message: "Article validation failed",
source: "external-api:/articles",
issues: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
code: i.code,
})),
});
}
7. Consider Performance
Zod is fast, but parsing large arrays on every request adds up. For ISR and SSG, this is a one-time cost. For SSR with high traffic, consider caching validated data in your data layer, or use Zod's .passthrough() or .strip() strategically to avoid deep-parsing enormous payloads when you only need a few fields.
Conclusion
Combining Zod with SSR, SSG, and ISR gives you a rendering pipeline that is both fast and resilient. SSG lets you fail fast at build time when content is malformed, SSR ensures every request is validated against your data contracts before it reaches users, and ISR balances freshness with performance while Zod catches shape drift between regenerations. By validating at trust boundaries, reusing schemas across your stack, and handling errors gracefully with safeParse, you build a system where type safety extends beyond compile time and into the real, messy world of external data. The result is fewer production crashes, better debugging, and a single source of truth for what your data should look like — from the database to the rendered HTML.