Dynamic Page Builder with Strapi, Next.js, and Varnish: Real-World Trade-Offs
Learn how to let editors assemble pages in Strapi while keeping Next.js fast and cache‑friendly with Varnish. Practical patterns, pitfalls, and performance tricks for production.

Why a page builder matters
When a marketer asks for a new landing page, you don’t want to open a PR, rebuild the bundle, and wait for CI. A headless CMS gives you the data, but the front‑end still needs a predictable way to turn that data into markup. The sweet spot is a component‑driven page builder: developers ship reusable UI blocks once, editors compose them in Strapi, and the server renders a static HTML page that Varnish can cache for seconds.
High‑level architecture
Everything revolves around three layers:
- Strapi stores page JSON – an ordered list of component IDs and their props.
- Next.js fetches that JSON at build time (or on‑demand) and maps each entry to a React component.
- Varnish sits in front of the Next.js server, caching the fully rendered HTML for the TTL you choose.
This separation gives you two big wins: editors never touch code, and the cache shields the Node process from traffic spikes.
Component contracts in TypeScript
Define a shared contract between Strapi and the front‑end. In components.d.ts you might have:
export type PageComponent = {
__component: string; // e.g. "hero.banner"
id: string;
props: Record<string, any>;
};
export type PageData = {
slug: string;
components: PageComponent[];
};Every UI block lives in components/ and exports a render function that receives props. This keeps the runtime mapping trivial:
import * as UI from '@/components';
export default function Page({data}: {data: PageData}) {
return (
<>{data.components.map(c => {
const Comp = UI[c.__component];
return Comp ? <Comp key={c.id} {...c.props} /> : null;
})}</>
);
}
Fetching and caching with getStaticProps
Next.js can generate a page at build time or on‑demand using revalidate. For a dynamic builder you usually prefer on‑demand ISR so editors see changes in minutes, not hours.
export async function getStaticProps({params}) {
const res = await fetch(`${process.env.STRAPI_URL}/pages/${params.slug}`);
const data: PageData = await res.json();
return {props: {data}, revalidate: 60}; // 1 min cache, Varnish adds another layer
}
export async function getStaticPaths() {
const res = await fetch(`${process.env.STRAPI_URL}/pages`);
const pages = await res.json();
return {paths: pages.map(p => ({params: {slug: p.slug}})), fallback: 'blocking'};
}
Varnish sees the final HTML and stores it for the TTL you configure (often 5‑10 minutes). When a page is edited, you can hit a Strapi webhook that sends a PURGE request to Varnish, forcing the next request to hit Next.js and refresh the ISR cache.
Production trade‑offs
Cache invalidation is the hardest part. If you rely only on TTL, editors may see stale content for up to that period. A webhook‑driven purge solves it but adds operational complexity – you need a small edge service that translates Strapi events into Varnish PURGE /slug calls.
Component versioning can bite you. If you change the props shape of a component, existing page JSON may break. Guard against this by adding a version field to each component payload and handling missing props gracefully.
Bundle size grows with every new UI block. Keep the component directory flat, lazy‑load heavy widgets with next/dynamic, and audit the bundle with next build --profile.
What didn’t work
We tried a pure client‑side render where the browser assembled the page from the JSON. It felt snappy on a dev machine, but in production the first‑contentful paint doubled because the browser waited for the whole component tree to hydrate. Moving the assembly to the server (SSR/ISR) gave us a complete HTML payload that Varnish could cache, cutting TTFB by 70%.
Another dead‑end was storing component HTML fragments in Strapi. It sounded convenient, but it duplicated logic between CMS and front‑end, made A/B testing impossible, and broke the single‑source‑of‑truth principle.
Bottom line
Combine Strapi’s flexible content model, Next.js’s ISR, and Varnish’s HTTP cache, and you get a page builder that scales without a dev‑team on standby. The key is to treat the JSON as a contract, keep the rendering on the server, and automate cache purges. If you respect those boundaries, you’ll spend more time iterating on UI and less time fighting stale pages.