How the Next.js Starter Works

This is a deep dive into how the Agility Next.js Starter is put together — routing, rendering, components, data fetching, caching, and preview. It's built on the App Router with React Server Components and Cache Components, the same model described in Rendering & Data Fetching with Next.js and Caching with Next.js and Agility.

Core Concepts

Content-Driven Architecture

The starter is built on the principle that content drives everything:

Agility CMS (content & structure)
         ↓
   Sitemap + Pages
         ↓
  React Components
         ↓
  Prerendered HTML
  • Editors control page structure in Agility CMS.
  • Developers define component behavior in React.
  • Next.js prerenders optimized pages.
  • Users get fast page loads.

Server-First Rendering

By default, everything is a React Server Component:

// Default: Server Component (async) — fetch directly, ship no JS
export default async function MyComponent({ module }) {
  const data = await fetchData()
  return <div>{data.title}</div>
}

// Only when you need interactivity: a Client Component
"use client"
export default function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Separation of Concerns

LayerLocationResponsibility
Presentationcomponents/UI rendering
Domain logiclib/cms-content/App-specific content shaping
CMS utilitieslib/cms/Generic, cached CMS reads
Typeslib/types/TypeScript interfaces
Routingapp/Next.js routing

The Page Lifecycle

1. Static params

At build time, Next.js asks which pages exist. The starter answers from the Agility sitemap:

// app/[...slug]/page.tsx
export async function generateStaticParams() {
  const sitemap = await getSitemapFlat({ locale: "en-us", preview: false })
  return Object.values(sitemap).map((node) => ({
    slug: node.path.split("/").filter(Boolean),
  }))
}

2. Metadata

Each page produces SEO metadata via generateMetadata:

export async function generateMetadata({ params }) {
  const { slug } = await params
  const { page } = await getAgilityPage({ slug: slug ?? [], locale: "en-us", preview: false })
  return {
    title: page.title,
    description: page.seo?.metaDescription,
    openGraph: { title: page.title, description: page.seo?.metaDescription },
  }
}

3. Rendering

export default async function Page({ params }) {
  const { slug } = await params
  const { page } = await getAgilityPage({ slug: slug ?? [], locale: "en-us", preview: false })
  const Template = getPageTemplate(page.templateName)
  return <Template page={page} />
}

4. In production

Because every CMS read is cached and tagged (see Caching below), a published page is served from the prerendered cache — instantly. When an editor publishes, Agility's webhook invalidates exactly the tags that changed, and the affected pages rebuild on the next request. There's no fixed revalidate timer to wait out.

Dynamic Routing

Instead of a file per page, the starter uses a single catch-all route:

app/
├─ layout.tsx        # root layout
└─ [...slug]/
   ├─ page.tsx       # handles /, /about, /blog, /blog/post-1, …
   ├─ error.tsx      # error boundary
   └─ not-found.tsx  # 404

[...slug] matches any path; getAgilityPage resolves it against the Agility sitemap to the right page, template, and components.

URLslug paramCMS page
/[]Homepage
/about['about']About
/blog/my-post['blog','my-post']Blog post

Component Architecture

Component Registry

Agility Components are mapped to React components by name:

// components/agility-components/index.ts
import { Module } from "@agility/nextjs"
import Heading from "./Heading"
import RichTextArea from "./RichTextArea"

const allModules: Module[] = [
  { name: "Heading", module: Heading },
  { name: "RichTextArea", module: RichTextArea },
]

export const getModule = (name: string) =>
  allModules.find((m) => m.name.toLowerCase() === name.toLowerCase())?.module || null

The @agility/nextjs API still calls these "modules" in code — in the Agility UI they're Components. Same thing.

Component Props

import { UnloadedModuleProps } from "@agility/nextjs"

interface IMyComponent { heading: string; content: string }

export default async function MyComponent({ module, page, languageCode }: UnloadedModuleProps) {
  const { fields } = module as { fields: IMyComponent }
  return (
    <section>
      <h2>{fields.heading}</h2>
      <div>{fields.content}</div>
    </section>
  )
}

Server by default, client when interactive

Server Components can fetch directly; add "use client" only where you need state/effects. A common pattern is a Server Component that fetches and hands data to a small Client Component for interactivity:

// PostsListing.tsx (server) — fetches
export default async function PostsListing({ module }) {
  const { posts } = await getPostListing({ take: 10 })
  return <PostsListingClient initialPosts={posts} />
}

// PostsListing.client.tsx (client) — filtering / infinite scroll
"use client"
export default function PostsListingClient({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts)
  return <div>{/* interactive UI */}</div>
}

Page Templates

Templates define layout and render Components into named zones with <ContentZone>:

// components/agility-pages/MainTemplate.tsx
import { ContentZone } from "@agility/nextjs"
import { getModule } from "../agility-components"

export default function MainTemplate({ page }) {
  return (
    <div className="max-w-7xl mx-auto">
      <ContentZone name="MainContent" page={page} getModule={getModule} />
    </div>
  )
}

<ContentZone> looks up page.zones.MainContent, resolves each Component via getModule, and renders it. Templates can declare multiple zones (e.g. MainContent + Sidebar), and are resolved by name through a template registry:

// components/agility-pages/index.ts
export const getPageTemplate = (name: string) =>
  ({ MainTemplate, TwoColumnTemplate } as Record<string, any>)[name] || MainTemplate

Data Fetching Strategy

Content flows through three tiers, so business logic stays out of both the SDK and your components:

Component  (what to display)
   ↓  getPostListing()
Domain     lib/cms-content/  (how to build "blog posts with URLs")
   ↓  getContentList()
CMS        lib/cms/          (cached, tagged Agility reads)
   ↓  @agility/content-fetch
Agility CMS API

CMS layer — a thin cached wrapper (this is the Cache Components pattern; see the caching guide):

// lib/cms/getContentItem.ts
import { cacheTag, cacheLife } from "next/cache"
import { connection } from "next/server"

export const getContentItem = async <T>(params) => {
  if (params.preview) {
    await connection()               // preview is never cached
    return fetchContentItem<T>(params)
  }
  return cachedContentItem<T>(params)
}

const cachedContentItem = async <T>(params) => {
  "use cache"
  cacheTag(`agility-content-${params.contentID}-${params.languageCode}`)
  cacheLife("days")
  return fetchContentItem<T>({ ...params, preview: false })
}

Domain layer — app-specific shaping (compose CMS reads, add computed URLs/excerpts):

// lib/cms-content/getPostListing.ts
export async function getPostListing({ take = 10, skip = 0 }) {
  const { items } = await getContentList<IPost>({ referenceName: "posts", languageCode: "en-us", take, skip })
  const sitemap = await getSitemapFlat({ locale: "en-us", preview: false })
  const blog = Object.values(sitemap).find((n) => n.name === "Blog")
  return items.map((p) => ({ ...p.fields, url: `${blog?.path}/${p.fields.slug}` }))
}

Component layer — just renders what the domain layer returns.

Caching & Performance

The starter uses Cache Components (cacheComponents: true). In short:

  • Every CMS read is wrapped in "use cache" and tagged with a stable key — agility-content-{contentID}-{locale}, agility-content-{referenceName}-{locale}, agility-page-{pageID}-{locale}, agility-sitemap-flat-{locale}.
  • Pages prerender to a static shell and are served from cache.
  • On publish, Agility's webhook calls revalidateTag(tag, "max") for the tags that changed, so only the affected pages rebuild — editors see updates almost immediately.
  • Preview/draft reads bypass the cache entirely via connection().

The complete setup, the tag contract, and the gotchas are in Caching with Next.js and Agility — the canonical reference. Don't hand-tune export const revalidate / dynamic per route; Cache Components handles that.

Preview Mode

Preview lets editors see unpublished drafts. It's built on Next's draftMode():

  1. Agility opens a preview URL with an agilitypreviewkey. A route (or middleware) validates the key and calls draftMode().enable(), then redirects to the real page.
  2. With draft mode on, getAgilityContext() reports isPreview: true, so the CMS wrappers take their uncached connection() branch and request the preview API key — returning the latest draft content.
  3. A small PreviewBar client component shows preview is active and can exit draft mode.

Because preview is uncached and per-request, drafts always reflect the editor's latest save. See Rendering & Data Fetching with Next.js.

Image Optimization

Use AgilityPic for Agility images — it renders a responsive <picture> backed by Agility's image API:

import { AgilityPic } from "@agility/nextjs"

<AgilityPic image={fields.image} fallbackWidth={800} className="rounded-lg" />

See Using the AgilityPic Component for the full API. For non-CMS images, use Next's own <Image>.

API Routes

Revalidate (the publish webhook)

// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache"

export async function POST(request: Request) {
  const p = await request.json()
  const locale = p.languageCode
  if (p.contentID)     revalidateTag(`agility-content-${p.contentID}-${locale}`, "max")
  if (p.referenceName) revalidateTag(`agility-content-${p.referenceName.toLowerCase()}-${locale}`, "max")
  if (p.pageID) {
    revalidateTag(`agility-page-${p.pageID}-${locale}`, "max")
    revalidateTag(`agility-sitemap-flat-${locale}`, "max")
  }
  return Response.json({ revalidated: true })
}

Configure it in Agility under Settings → Webhooks pointing at POST /api/revalidate. (revalidateTag(tag, "max") is the Next 16 form; on Next 15 use revalidateTag(tag).)

Summary

The starter is a modern App Router build:

  • Server-first rendering (RSC) for performance.
  • Catch-all routing driven by the Agility sitemap.
  • A component + template registry for editor-composed pages.
  • Cache Components caching with tag-based, publish-driven invalidation.
  • draftMode() preview that bypasses the cache.
  • Type safety throughout.