---
title: "Build a Unified Routing System with Next.js ISR in 5 Steps"
slug: "building-a-unified-multi-source-routing-system-with-nextjs-isr"
published: "2025-11-07"
updated: "2025-11-12"
validated: "2025-11-12"
categories:
  - "Next.js"
tags:
  - "Next.js ISR"
  - "Incremental Static Regeneration"
  - "Unified routing systems"
  - "Builder.io integration"
  - "Shopify routing"
  - "Multi-source data"
  - "Performance optimization"
  - "Web development tutorial"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js@16"
  - "typescript@>=4.0"
status: "stable"
llm-purpose: "Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application."
llm-prereqs:
  - "Access to Next.js"
  - "Access to Builder.io"
  - "Access to Shopify"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application."
---

**Summary Triples**
- (Unified route, consolidates, Builder.io pages, Shopify pages, and custom code pages into a single routing layer)
- (Next.js 16 ISR, enables, cached static outputs with configurable revalidation for mixed static/dynamic content)
- (Single dynamic route (catch-all), delegates, request handling to a resolver that decides the content source based on path)
- (Resolver function, should check, Builder.io first (if used), Shopify next, then local/custom routes (or configured precedence))
- (Avoid hardcoding, by using, a registry or index (e.g., CMS mapping or remote manifest) to map slugs to sources)
- (ISR revalidation, is implemented with, Next.js revalidate config (server-side revalidate or fetch({ next: { revalidate } })) to control cache TTL)
- (Special cases (e.g., Builder.io needs Shopify metaobjects), should be solved, by composing fetches in the resolver and merging metadata before rendering)
- (Scalability, is achieved by, extending the resolver to add new sources rather than creating new route files)
- (Testing & deployment, requires, local dev verification and deploying to a platform that preserves ISR behavior (Vercel recommended))
- (Outcome, reduces, duplicate data-fetching logic, route fragmentation, and per-source hardcoding)

### {GOAL}
Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application.

### {PREREQS}
- Access to Next.js
- Access to Builder.io
- Access to Shopify
- Access to TypeScript

### {STEPS}
1. Understand the Problem with Separate Routes
2. Learn About ISR in Next.js
3. Create the Unified Route Handler
4. Configure Dynamic Data Handling
5. Test and Optimize Your Solution

<!-- llm:goal="Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application." -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Builder.io" -->
<!-- llm:prereq="Access to Shopify" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application." -->

# Build a Unified Routing System with Next.js ISR in 5 Steps
> Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application.
Matija Žiberna · 2025-11-07

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.

```typescript
// 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:
1. Skips this page during the build (not in generateStaticParams)
2. Renders it on-demand when first visited
3. Caches it for 60 seconds
4. Fetches fresh Shopify metaobjects matching the page slug

## The Builder.io Helper Functions

Next, create the helper functions that power the system.

```typescript
// 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`:

1. The route fetches Builder.io content for `/events`
2. Sees `requiresDynamicData: true`
3. Calls `getMetaobjectsByType("events")` to fetch all events from Shopify
4. Transforms the raw Shopify data (formatting dates, extracting images, etc.)
5. Filters events into upcoming and past
6. Passes the structured data to Builder.io as `data`
7. 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:

```typescript
// 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:

```typescript
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

## LLM Response Snippet
```json
{
  "goal": "Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application.",
  "responses": [
    {
      "question": "What does the article \"Build a Unified Routing System with Next.js ISR in 5 Steps\" cover?",
      "answer": "Master the art of unified routing in Next.js with ISR. Follow our guide to consolidate multiple sources for a powerful application."
    }
  ]
}
```