Push content changes to deployed Next.js sites in ~2 seconds via signed webhook, without full Docker rebuilds.
What is ICD?
Instant Content Deployment pushes content changes from the CMS admin directly to your deployed Next.js site via a signed webhook. Instead of triggering a full Docker rebuild (~73 seconds), the site receives the updated document, writes it to disk, and calls revalidatePath() — all in about 2 seconds.
When to use ICD
ICD works with Next.js SSR sites that have a persistent filesystem:
- Fly.io with volumes
- Self-hosted Docker
- Any environment where the content directory is writable at runtime
ICD does not apply to static builds (Vercel, Netlify, GitHub Pages) where content is baked at build time.
How it works
- User saves content in CMS admin
- CMS sends the document JSON as an HMAC-SHA256 signed POST to the site's
/api/revalidateendpoint - The endpoint verifies the signature, writes the document to disk, and calls
revalidatePath() - Next.js serves fresh content on the next request
- Full Docker deploy is skipped when the revalidation webhook succeeds
Setup
1. Add the revalidation endpoint
Create app/api/revalidate/route.ts in your Next.js site:
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
const SECRET = process.env.REVALIDATE_SECRET;
const CONTENT_DIR = process.env.CONTENT_DIR ?? join(process.cwd(), "content");
export async function POST(request: NextRequest) {
const signature = request.headers.get("x-cms-signature");
const body = await request.text();
if (SECRET) {
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 401 });
}
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(body).digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
const payload = JSON.parse(body) as {
paths?: string[];
collection?: string;
slug?: string;
action?: string;
document?: Record<string, unknown> | null;
};
if (payload.collection && payload.slug) {
const filePath = join(CONTENT_DIR, payload.collection, `${payload.slug}.json`);
if (payload.action === "deleted") {
if (existsSync(filePath)) unlinkSync(filePath);
} else if (payload.document) {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(payload.document, null, 2), "utf-8");
}
}
const paths: string[] = payload.paths ?? ["/"];
for (const p of paths) {
revalidatePath(p);
}
return NextResponse.json({
revalidated: true,
paths,
collection: payload.collection,
slug: payload.slug,
timestamp: new Date().toISOString(),
});
}2. Set the environment variable
Generate a secret and set it on your deployed site:
# Generate
openssl rand -hex 32
# Fly.io
fly secrets set REVALIDATE_SECRET=<generated-secret>
# Docker / .env
REVALIDATE_SECRET=<generated-secret>3. Configure CMS admin
In Site Settings, add these fields to the site registry entry:
| Field | Value |
|---|---|
revalidateUrl | https://your-site.fly.dev/api/revalidate |
revalidateSecret | The same secret set as REVALIDATE_SECRET |
Payload format
The CMS sends a POST request with:
- Header
x-cms-signature:sha256=<hmac-hex> - Body (JSON):
- slug — document slug
- action — created, updated, or deleted
- document — full document JSON (null for deletes)
- paths — array of URL paths to revalidate
Fallback behavior
If the webhook fails (site is down, authentication error, network timeout), the CMS falls back to a full Docker deploy automatically.
Security
- The secret must be generated with
openssl rand -hex 32(64 hex characters) - Never hardcode the secret in source code — always use environment variables
- The endpoint uses HMAC-SHA256 with timing-safe comparison to prevent signature forgery
- Without a valid
REVALIDATE_SECRET, the endpoint rejects all requests