See Agility CMS in action. Watch a product demo
Handling Preview URLs & Request Lifecycle (App Router)
In the Agility CMS Next.js Starter (using the App Router), handling Preview URLs utilizes the Next.js draftMode functionality found in next/headers. This allows the application to securely bypass static caching and fetch the latest content drafts.
⚠️ Important: Save Your Content First The Preview Mode fetches the latest Saved version of your content (Preview state). If you type into a field in Agility CMS but do not click Save, the API cannot see those changes yet unless you're using WebStudio.
The lifecycle consists of three distinct components:
- The Entry Point (
app/api/preview/route.ts) - The Page Logic (
app/[...slug]/page.tsx) - The Exit Point (
app/api/exit/route.ts)
1. The Entry Point: app/api/preview/route.ts
This API route is triggered when a user clicks "Preview" in the Agility CMS Manager. Its primary role is to validate the security handshake and enable "Draft Mode" via cookies.
Function: Validates the agilitypreviewkey and enables draftMode.
// app/api/preview/route.ts
import { validatePreview, getDynamicPageURL } from "@agility/nextjs/node";
import { draftMode } from 'next/headers'
import { NextRequest, NextResponse } from "next/server";
/**
* This endpoint is used as a rewrite for preview requests from middleware.
* Therefore, the URL will be the original preview request url...
* @param request
* @param res
* @returns
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const agilityPreviewKey = searchParams.get("agilitypreviewkey") || ""
//locale is also passed in the querystring on preview requests
const locale = searchParams.get("lang")
const slug = searchParams.get("slug") || "/"
const ContentID = searchParams.get('ContentID')
//validate our preview key, also validate the requested page to preview exists
const validationResp = await validatePreview({
agilityPreviewKey,
slug
});
console.log("validationResp", validationResp)
if (validationResp.error) {
return NextResponse.json({ message: validationResp.message }, { status: 401 });
}
let previewUrl = slug;
//if we have a content id, get the dynamic page url for it
if (ContentID) {
const dynamicPath = await getDynamicPageURL({ contentID: Number(ContentID), preview: true, slug: slug || undefined });
if (dynamicPath) {
previewUrl = dynamicPath;
}
}
//enable draft/preview mode
(await draftMode()).enable()
// Redirect to the slug
// Construct an absolute URL for the redirect
const baseUrl = `${request.nextUrl.protocol}//${request.nextUrl.host}`;
let url = `${baseUrl}${previewUrl}`;
if (url.includes("?")) {
url = `${url}&preview=1`;
} else {
url = `${url}?preview=1`;
}
return NextResponse.redirect(url, 307);
}
2. The Page Logic: app/[...slug]/page.tsx
In the App Router, the page component itself determines whether to request Preview or Published content. This logic is centralized in the main page file.
Function: checks the environment and cookie state to determine the preview boolean.
// app/[...slug]/page.tsx
import { getPageTemplate } from "components/agility-pages"
import { PageProps, getAgilityPage } from "lib/cms/getAgilityPage"
import { getAgilityContext } from "lib/cms/getAgilityContext"
import agilitySDK from "@agility/content-fetch"
import { Metadata, ResolvingMetadata } from "next"
import { resolveAgilityMetaData } from "lib/cms-content/resolveAgilityMetaData"
import NotFound from "./not-found"
import InlineError from "components/common/InlineError"
import { SitemapNode } from "lib/types/SitemapNode"
import { notFound } from "next/navigation"
export const revalidate = 60
export const runtime = "nodejs"
export const dynamic = "force-static"
/**
* Generate the list of pages that we want to generate a build time.
*/
export async function generateStaticParams() {
const isDevelopmentMode = process.env.NODE_ENV === "development";
const isPreview = isDevelopmentMode;
const apiKey = isPreview ? process.env.AGILITY_API_PREVIEW_KEY : process.env.AGILITY_API_FETCH_KEY;
const agilityClient = agilitySDK.getApi({
guid: process.env.AGILITY_GUID,
apiKey,
isPreview,
});
const languageCode = process.env.AGILITY_LOCALES || "en-us";
agilityClient.config.fetchConfig = {
next: {
tags: [`agility-sitemap-flat-${languageCode}`],
revalidate: 60,
},
};
// Get the flat sitemap and generate the paths
const sitemap: { [path: string]: SitemapNode } = await agilityClient.getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode,
});
const paths = Object.values(sitemap)
.filter((node, index) => {
if (node.redirect !== null || node.isFolder === true || index === 0) return false;
return true;
})
.map((node) => {
return {
slug: node.path.split("/").slice(1),
};
});
console.log("Pre-rendering", paths.length, "static paths.");
return paths;
}
/**
* Generate metadata for this page
*/
export async function generateMetadata(
props: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { params } = props; // Remove the 'await' here
const { locale, sitemap, isDevelopmentMode, isPreview } = await getAgilityContext();
const agilityData = await getAgilityPage({ params });
if (!agilityData.page) return {};
return await resolveAgilityMetaData({
agilityData,
locale,
sitemap,
isDevelopmentMode,
isPreview,
parent,
});
}
export default async function Page({ params }: PageProps) {
const agilityData = await getAgilityPage({ params });
if (!agilityData.page) notFound();
const AgilityPageTemplate = getPageTemplate(agilityData.pageTemplateName || "");
return (
<div data-agility-page={agilityData.page?.pageID} data-agility-dynamic-content={agilityData.sitemapNode.contentID}>
{AgilityPageTemplate ? (
<AgilityPageTemplate {...agilityData} />
) : (
<InlineError message={`No template found for page template name: ${agilityData.pageTemplateName}`} />
)}
</div>
);
}
Note on
NODE_ENV: The check forprocess.env.NODE_ENV === "development"ensures that developers running the site locally (localhost) always see the latest changes immediately without needing to manually invoke the Preview API.
3. The Exit Point: app/api/exit/route.ts
This route allows a user to "Turn off" preview mode and return to viewing the live/published version of the site (simulating a standard public visitor).
Function: Disables draftMode and clears the cookie.
// app/api/exit/route.ts
"use server";
import { getDynamicPageURL } from '@agility/nextjs/node';
import { draftMode } from 'next/headers'
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const slug = searchParams.get('slug');
const ContentID = searchParams.get('ContentID');
//disable draft/preview mode
(await draftMode()).disable()
let url = new URL(slug || '', request.nextUrl.origin).toString();
if (ContentID) {
const dynamicPath = await getDynamicPageURL({ contentID: Number(ContentID), preview: false, slug: slug || undefined });
if (dynamicPath) {
url = dynamicPath;
}
}
// Remove the preview URL param if it exists
const urlObj = new URL(url);
urlObj.searchParams.delete('preview');
url = urlObj.toString();
// Redirect to the url
return NextResponse.redirect(url, { status: 307, headers: { "Location": url } })
}
Summary of the Data Flow
- User Clicks Preview (Agility CMS)
- Opens the preview URL:
mysite.com/api/preview?slug=/about&agilitypreviewkey=xyz
- Opens the preview URL:
- Entry (
api/preview)- Validates the Security Key.
- Calls
draftMode().enable()to set the cookie. - Redirects the browser to
/about.
- Rendering (
page.tsx)- The page loads and calls
draftMode().isEnabled. - Since the cookie exists, it sets
isPreview = true. - The SDK fetches Draft content from the Agility API.
- The page loads and calls
- Result
- The user sees the unpublished, work-in-progress content.
Configure the Preview URL
⚠️ Important: Don't forget to configure your Agility instance

Read the full guide on setting up your preview in your Agility CMS instances.
https://agilitycms.com/docs/developers/setting-up-preview
Troubleshooting
If Previews are not working in the App Router:
- Check
.env.local: EnsureAGILITY_SECURITY_KEYmatches the key generated in Agility CMS. - Check Localhost: Remember that
process.env.NODE_ENV === 'development'forces preview on. To test "Live" mode locally, you must runnpm run buildandnpm run start. - Cookie Blocking: If you are using Agility CMS inside an iframe (WebStudio), ensure your browser allows third-party cookies, or open the preview in a new tab.