Build a Unified Routing System with Next.js ISR in 5 Steps
Learn to integrate multiple data sources into a single routing layer using Next.js 16's Incremental Static Regeneration.

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Building a Unified Multi-Source Routing System with Next.js ISR
Introduction
I was working on a project where the marketing team wanted to build landing pages with a visual editor (Builder.io), the e-commerce section lived in Shopify, and we had custom pages written directly in code. Managing three completely separate routing systems felt wrong—it was fragmenting the user experience and creating maintenance headaches.
After experimenting with various approaches, I discovered that Next.js 16's Incremental Static Regeneration (ISR) could bridge all of this together. The result is a single unified route that intelligently routes to the right data source while maintaining optimal performance for both static and dynamic content.
This guide walks you through exactly how to build this system. By the end, you'll understand how to combine multiple content sources into one seamless routing layer, optimize performance with ISR caching, and make the whole thing scale without hardcoding.
The Problem with Separate Routes
Before we built the unified system, the codebase looked like this:
app/
├── [shopifyPage]/page.tsx // Shopify pages only
├── (builderPages)/
│ ├── presse/page.tsx // Builder.io page
│ ├── events/page.tsx // Builder.io page
│ └── eigendesign/page.tsx // Builder.io page
└── custom-routes/ // Custom pages
This fragmentation created several problems. First, duplicate route handlers meant duplicate data-fetching logic, metadata handling, and caching strategies. Second, if Builder.io pages needed special treatment (like the /events page fetching Shopify metaobjects), we had to hardcode it in each individual file. Third, scaling to new content sources meant adding more nested routes and more branching logic.
The real issue was architectural: we weren't recognizing that all these pages shared the same fundamental needs—they just came from different sources.
Understanding ISR: Static + Dynamic in One Route
Before building the unified system, you need to understand how ISR works in Next.js.
Incremental Static Regeneration lets you have the best of both worlds. Here's the key insight: when you define generateStaticParams() in a dynamic route, Next.js pre-renders those pages at build time. Pages NOT in that list are rendered on-demand when first visited, then cached. The revalidate setting controls how often the cache refreshes.
This means you can have:
- Static pages: Pre-rendered at build time, served instantly from static HTML
- Dynamic pages: Rendered on first request with fresh data, then cached for 60 seconds
- All in a single route handler
The magic is that the same route handler serves both types. The route doesn't need to know which pages are static vs dynamic—it just renders whatever is needed and lets ISR handle the caching strategy.
The Three-Tier Architecture
The unified route works by attempting to load content from multiple sources in order:
User visits /slug
↓
Try Builder.io (primary)
├─ Found → Check if dynamic
│ ├─ Yes → Fetch Shopify data, render with fresh content
│ └─ No → Render pre-built page
└─ Not found → Fall back to Shopify
├─ Found → Render Shopify page
└─ Not found → 404
This hierarchy makes sense because Builder.io is your primary marketing/CMS platform, Shopify is the fallback for e-commerce pages, and everything else returns 404.
Building the Unified Route
Let's start by creating the main route handler. This single file replaces all the separate route files.
// File: app/[slug]/page.tsx
/**
* Unified Dynamic Page Route Handler
*
* This single route handles three types of content:
* 1. Builder.io static pages (pre-rendered at build)
* 2. Builder.io dynamic pages (rendered on-demand with ISR)
* 3. Shopify pages (fallback source)
*/
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Prose from "components/prose";
import { getPage, getPages, getMetaobjectsByType } from "lib/shopify";
import { getAllBuilderPages, getBuilderPageContent } from "lib/builderio";
import { Content, isPreviewing } from "@builder.io/sdk-react";
import { formatDateToGerman, extractPlainTextFromRichText } from "lib/utils";
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string>>;
}
const PUBLIC_API_KEY = process.env.NEXT_PUBLIC_BUILDER_IO_PUBLIC_KEY!;
/**
* ISR Configuration: Revalidate every 60 seconds
*
* How this works:
* - Pages in generateStaticParams() are pre-rendered at build time
* - Pages NOT in generateStaticParams() are rendered on-demand, cached 60 seconds
* - After 60 seconds, next request triggers a background revalidation
*/
export const revalidate = 60;
/**
* Pre-generate static routes for pages that don't need fresh data
*/
export async function generateStaticParams() {
const shopifyPages = await getPages();
const builderPages = await getAllBuilderPages();
const slugMap = new Map<string, { slug: string }>();
// Add Shopify pages
shopifyPages.edges.forEach((edge) => {
slugMap.set(edge.node.handle, { slug: edge.node.handle });
});
// Add Builder.io static pages (dynamic ones are excluded)
builderPages.forEach((page) => {
if (!slugMap.has(page.slug)) {
slugMap.set(page.slug, page);
}
});
return Array.from(slugMap.values());
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
// Try Builder.io first for metadata
const builderContent = await getBuilderPageContent(params.slug);
if (builderContent) {
return {
title:
builderContent.data?.title ||
builderContent.data?.meta?.title ||
params.slug,
description:
builderContent.data?.meta?.description ||
builderContent.data?.description ||
undefined,
};
}
// Fall back to Shopify metadata
try {
const shopifyPage = await getPage(params.slug);
if (shopifyPage) {
return {
title: shopifyPage.title,
description: shopifyPage.bodySummary || undefined,
};
}
} catch {
// Continue to 404
}
return { title: params.slug };
}
export default async function DynamicPage(props: PageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
// Step 1: Try to fetch Builder.io content first
const builderContent = await getBuilderPageContent(params.slug, searchParams);
if (builderContent || isPreviewing(searchParams)) {
// Step 2: Check if this page requires dynamic Shopify data
const isDynamic = builderContent?.data?.requiresDynamicData;
let dynamicData: any = undefined;
/**
* Step 3: For dynamic pages, fetch and transform Shopify metaobjects
*
* The slug name automatically maps to the Shopify data source:
* - /events → getMetaobjectsByType("events")
* - /products → getMetaobjectsByType("products")
*
* No hardcoding needed—the slug tells us what data to fetch.
*/
if (isDynamic) {
const metaobjects = await getMetaobjectsByType(params.slug);
const items =
metaobjects?.edges?.map(({ node }: any) => {
const getFieldValue = (key: string) =>
node.fields.find((field: any) => field.key === key)?.value || "";
const getImageUrl = (key: string) => {
const field = node.fields.find(
(field: any) => field.key === key
) as any;
if (field?.reference && "image" in field.reference) {
return field.reference.image?.url || "";
}
return "";
};
const rawStartDatum = getFieldValue("startdatum");
const rawEndDatum = getFieldValue("enddatum");
const rawBeschreibung = getFieldValue("beschreibung");
return {
id: node.id,
handle: node.handle,
name: getFieldValue("name"),
startdatum: formatDateToGerman(rawStartDatum),
enddatum: formatDateToGerman(rawEndDatum),
adresse: getFieldValue("adresse"),
messestand: getFieldValue("messestand"),
uhrzeit: getFieldValue("uhrzeit"),
beschreibung: extractPlainTextFromRichText(rawBeschreibung),
bild: getImageUrl("bild"),
eventlink: getFieldValue("eventlink"),
_rawStartDate: rawStartDatum,
_rawEndDate: rawEndDatum,
};
}) || [];
/**
* Format data based on page type
*
* For /events: filter upcoming and past events
* For other pages: generic items structure
*/
if (params.slug === "events") {
const cleanItems = items.map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
const upcomingEvents = items
.filter((event: any) => {
if (!event._rawStartDate) return false;
const startDate = new Date(event._rawStartDate);
return startDate >= new Date();
})
.map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
const pastEvents = items
.filter((event: any) => {
if (!event._rawEndDate && !event._rawStartDate) return false;
const endDate = new Date(
event._rawEndDate || event._rawStartDate
);
return endDate < new Date();
})
.map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
// Keep Builder.io binding names
dynamicData = {
events: cleanItems,
eventsCount: cleanItems.length,
hasEvents: cleanItems.length > 0,
upcomingEvents,
pastEvents,
};
} else {
const cleanItems = items.map(({ _rawStartDate, _rawEndDate, ...item }: any) => item);
dynamicData = {
items: cleanItems,
itemCount: cleanItems.length,
hasItems: cleanItems.length > 0,
};
}
}
// Step 4: Render Builder.io page with dynamic data
return (
<Content
content={builderContent}
apiKey={PUBLIC_API_KEY}
model="page"
data={dynamicData}
/>
);
}
/**
* Step 5: Fallback to Shopify pages
*/
try {
const shopifyPage = await getPage(params.slug);
if (!shopifyPage) {
notFound();
}
return (
<article className="prose dark:prose-invert max-w-xl mx-auto">
<h1>{shopifyPage.title}</h1>
<Prose html={shopifyPage.body} />
</article>
);
} catch {
// Step 6: 404 if page not found in any source
notFound();
}
}
This single file replaces all the separate route handlers. The key insight is that each section builds on the previous one: we try Builder.io first, then Shopify, then 404. The route doesn't care which source the content came from—it just renders it.
Configuring Builder.io Pages
The system relies on a single custom field in Builder.io that controls behavior for each page.
In your Builder.io page model, add a custom field:
- Field name:
requiresDynamicData - Field type: Checkbox (boolean)
- Description: "Mark this page as dynamic if it needs fresh Shopify data"
For pages that should be pre-rendered (like /presse, /eigendesign), leave this unchecked. For pages that need fresh data (like /events), check the box.
When checked, the route automatically:
- Skips this page during the build (not in generateStaticParams)
- Renders it on-demand when first visited
- Caches it for 60 seconds
- Fetches fresh Shopify metaobjects matching the page slug
The Builder.io Helper Functions
Next, create the helper functions that power the system.
// File: lib/builderio/index.ts
/**
* Builder.io Integration Helper Functions
*
* Handles fetching pages for static pre-rendering and individual page content.
*/
const PUBLIC_API_KEY = process.env.NEXT_PUBLIC_BUILDER_IO_PUBLIC_KEY;
/**
* Fetch all publishable Builder.io pages that should be statically pre-rendered
*
* Pages with requiresDynamicData: true are automatically excluded.
* They'll be rendered on-demand instead.
*/
export async function getAllBuilderPages() {
if (!PUBLIC_API_KEY) {
console.warn("NEXT_PUBLIC_BUILDER_IO_PUBLIC_KEY not set");
return [];
}
try {
const response = await fetch(
`https://cdn.builder.io/api/v2/content/page?apiKey=${PUBLIC_API_KEY}&limit=100&status=published`,
{
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Builder.io API error: ${response.statusText}`);
}
const data = await response.json();
return (data.results || [])
.map((page: any) => {
const url = page.data?.url || page.target?.urlPath || null;
if (!url) return null;
const slug = url.replace(/^\//, "");
// Check the requiresDynamicData flag
const requiresDynamicData = page.data?.requiresDynamicData;
// Skip dynamic pages—they're rendered on-demand
if (requiresDynamicData && requiresDynamicData !== false) {
return null;
}
return slug ? { slug } : null;
})
.filter(Boolean);
} catch (error) {
console.error("Error fetching Builder.io pages:", error);
return [];
}
}
/**
* Fetch content for a specific Builder.io page by slug
*
* Returns the full page object including all custom fields.
* The requiresDynamicData field is accessible via content.data
*/
export async function getBuilderPageContent(
slug: string,
searchParams?: Record<string, string>
) {
const { fetchOneEntry, getBuilderSearchParams } = await import(
"@builder.io/sdk-react"
);
if (!PUBLIC_API_KEY) {
return null;
}
const urlPath = `/${slug}`;
try {
const content = await fetchOneEntry({
options: getBuilderSearchParams(searchParams || {}),
apiKey: PUBLIC_API_KEY,
model: "page",
userAttributes: { urlPath },
});
return content;
} catch (error) {
console.error(`Error fetching Builder.io page for slug "${slug}":`, error);
return null;
}
}
The getAllBuilderPages() function queries the Builder.io API for all published pages, then filters out any with requiresDynamicData: true. This means those pages aren't included in generateStaticParams(), so Next.js renders them on-demand instead.
The getBuilderPageContent() function is a thin wrapper around Builder.io's API that fetches a specific page and returns all its custom fields.
Real Example: The /events Page
Let's see how this works in practice with the /events page, which needs to fetch live event data from Shopify.
In Builder.io, you create a page at the /events URL and set requiresDynamicData: true. You design the page with components that bind to state.events, state.upcomingEvents, etc.
When someone visits /events:
- The route fetches Builder.io content for
/events - Sees
requiresDynamicData: true - Calls
getMetaobjectsByType("events")to fetch all events from Shopify - Transforms the raw Shopify data (formatting dates, extracting images, etc.)
- Filters events into upcoming and past
- Passes the structured data to Builder.io as
data - Builder.io components render with the live data
The page is then cached for 60 seconds. If someone visits again within 60 seconds, they get the cached version instantly. After 60 seconds, the next request triggers a background revalidation to fetch fresh data.
Here's what the data transformation looks like:
// Inside the isDynamic block in app/[slug]/page.tsx
if (params.slug === "events") {
const cleanItems = items.map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
const upcomingEvents = items.filter((event: any) => {
if (!event._rawStartDate) return false;
const startDate = new Date(event._rawStartDate);
return startDate >= new Date();
}).map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
const pastEvents = items.filter((event: any) => {
if (!event._rawEndDate && !event._rawStartDate) return false;
const endDate = new Date(event._rawEndDate || event._rawStartDate);
return endDate < new Date();
}).map(({ _rawStartDate, _rawEndDate, ...event }: any) => event);
dynamicData = {
events: cleanItems,
eventsCount: cleanItems.length,
hasEvents: cleanItems.length > 0,
upcomingEvents,
pastEvents,
};
}
The key point: this logic is generic. The slug name ("events") tells us what Shopify data to fetch. We transform it into a structure that matches what Builder.io expects. No hardcoding, no special configuration files—just the natural mapping from page name to data source.
Extending to New Page Types
The beauty of this system is how easily it scales. Let's say you want to add a /products page that shows products from Shopify.
First, create the Shopify metaobject type called "products". Then in Builder.io, create a page at /products, set requiresDynamicData: true, and design components that bind to state.items and state.itemCount.
The route already handles it. No code changes needed. The slug automatically maps to the Shopify data source, and the generic transformation applies.
If /products has special filtering or formatting needs (like /events does), you just add a conditional:
if (params.slug === "products") {
// Special products logic
} else if (params.slug === "testimonials") {
// Special testimonials logic
} else {
// Generic items structure
}
But the default case handles any metaobject type without changes.
Performance Characteristics
This system achieves optimal performance across all page types:
Static Builder.io pages (/presse, /eigendesign) are pre-rendered at build time and served as static HTML. The first byte takes milliseconds because there's no server work involved. They're loaded into the CDN and cached globally.
Dynamic Builder.io pages (/events) are rendered on first request, then cached for 60 seconds. The first visitor waits for the full render, but everyone else in that 60-second window gets the cached version. After 60 seconds, the next request triggers a background revalidation so the cache is always fresh.
Shopify fallback pages follow the same ISR caching, so they're also optimized.
The revalidate = 60 setting is a good default for most marketing sites. You can adjust it based on how frequently your content changes. For sites where events update hourly, use revalidate = 3600 (1 hour). For sites with less frequent changes, use revalidate = 86400 (1 day).
The Bigger Picture
This architecture solves a real problem for teams using multiple tools. You're no longer forced to choose between static performance and dynamic freshness, between no-code convenience and developer control. You get all three.
Marketing teams can use Builder.io for landing pages and promotional content, e-commerce teams can use Shopify for product pages, and developers can write custom code for specific needs—all unified behind a single routing layer that handles performance optimization automatically.
The slug-based data mapping means adding new content types requires zero backend changes. The requiresDynamicData field gives non-technical users control over caching behavior without touching code.
Conclusion
We started with a fragmented routing system that couldn't scale. By recognizing that all pages share the same needs—they just come from different sources—we built a unified route that intelligently routes to Builder.io, Shopify, or custom code, while using Next.js ISR to optimize performance across all cases.
The key insights you've learned:
- ISR lets you have static performance AND dynamic freshness in the same route
- Pages in
generateStaticParams()are pre-rendered; pages outside are on-demand - Slug names can automatically map to data sources without hardcoding
- Builder.io custom fields control behavior without code changes
- The same route handler serves all content types
This approach scales to teams with complex needs: multiple no-code tools, managed backends, and custom code, all working together seamlessly.
Let me know in the comments if you have questions about implementing this in your project, and subscribe for more practical development guides.
Thanks, Matija