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:

  1. The Entry Point (app/api/preview/route.ts)
  2. The Page Logic (app/[...slug]/page.tsx)
  3. 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 for process.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

  1. User Clicks Preview (Agility CMS)
    • Opens the preview URL: mysite.com/api/preview?slug=/about&agilitypreviewkey=xyz
  2. Entry (api/preview)
    • Validates the Security Key.
    • Calls draftMode().enable() to set the cookie.
    • Redirects the browser to /about.
  3. 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.
  4. Result
    • The user sees the unpublished, work-in-progress content.

Configure the Preview URL

⚠️ Important: Don't forget to configure your Agility instance

Agility CMS

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:

  1. Check .env.local: Ensure AGILITY_SECURITY_KEY matches the key generated in Agility CMS.
  2. Check Localhost: Remember that process.env.NODE_ENV === 'development' forces preview on. To test "Live" mode locally, you must run npm run build and npm run start.
  3. 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.