Authenticated Content and Protected Routes

Last updated: April 24, 2026

This guide is based on Next.js and Uniform App Router v20 and above

The problem

A CMS-driven website often needs to serve a mix of public and private content. Some pages are open to everyone, some should only be visible to logged-in users, and even on public pages the experience may need to change depending on who is viewing it. These requirements create three distinct challenges:

  1. Personalizing public content for authenticated users -- A public page (e.g. a marketing landing page) may need to show different calls-to-action, pricing, or messaging depending on whether the visitor is logged in and what attributes they carry (role, brand, account status, etc.). The page itself is not restricted, but its content adapts.

  2. Protecting private content on otherwise public routes -- A single page may contain a mixture of public and private sections. For example a product page might be visible to everyone but include an "order now" panel that should only render for authenticated users. The route is not protected, but individual content blocks within it are.

  3. Protecting entire routes -- Certain paths (e.g. /my-account and everything beneath it) must be completely inaccessible to unauthenticated visitors. Any request to these paths should redirect to the login flow before the page ever renders.

Solving all three in a CMS-managed, multi-locale Next.js application introduces additional complexity:

  • CMS authors, not developers, control which pages are protected. The list of protected routes must update automatically when a content author publishes or unpublishes a page with an accessType parameter in Uniform.

  • Protection must happen at the edge, before page code runs. Relying solely on client-side checks would leak markup and data to unauthenticated visitors.

  • The protected-route list must be fast to read on every request. Fetching from the CMS on every page load would be too slow.

  • Content authors need to preview protected pages inside the Uniform visual editor without being blocked by authentication.

How the solution is structured

The implementation addresses each layer with a different mechanism:

Layer

Mechanism

Where it runs

Personalized public content

Uniform quirks (authenticated, ...) passed through middleware into the Uniform Context. Components use personalization rules to swap slot content based on the authenticated quirk.

Quirks; are run on both client and server side. They are visible in cookies.

Private content blocks

The AuthContainer component reads context.pageState.keys.authenticated and conditionally renders its authorized or unauthorized slot.

Server-side component rendering.

Protected routes

Middleware checks every request against a list stored in Vercel Edge Config. If the route matches and the user has no session, middleware redirects to /login with a return URL. The list is kept in sync by a webhook that Uniform fires on composition publish/delete.

Next.js middleware (edge runtime).

Architecture

image.png

Personalizing public content with quirks

Quirks let Uniform's built-in personalization engine vary content based on visitor attributes. To personalize for authenticated users, pass an authenticated quirk from middleware so that CMS authors can create personalization rules against it.

In this example, Uniform is is controlling visibility of variants rather than adding content which is personal to an individual user.

Passing quirks from middleware

// src/middleware.ts (simplified)

const user = await getDisplayUserFromRequest(req);

return uniformMiddleware({
  quirks: {
    authenticated: String(!!user),  // "true" or "false"
  },
  locale,
  // ...
})(req);

Quirk values are always strings. Booleans must be converted with String(!!value). Uniform will not coerce types for you, passing a raw boolean will silently fail to match personalization criteria.

Registering the quirk in your Uniform project

No additional code changes are needed, Uniform's personalization components will pick up the quirk automatically. However you must register the quirk in your Uniform project so that CMS authors can build rules against it:

  1. In the Uniform dashboard, go to Personalization > Quirks

  2. Add a quirk with the key authenticated and values true / false

  3. Authors can then use this quirk as criteria in personalization components to show different content variants depending on login status

See the Uniform quirks documentation for full setup details.

How this differs from the other layers

Quirks are stored in cookies and evaluated on both the client and server. This makes them suitable for personalizing public content (e.g. swapping a CTA) but not for protecting private content. The cookie values are visible in the browser and a motivated user could forge them. For content that must not be visible to unauthenticated users, use the AuthContainer (keys) or protected routes (middleware redirect).

AuthContainer -- private content blocks on public pages

The AuthContainer is a Uniform component with two slots: authorized and unauthorized. It decides which slot to render based on a key that middleware sets on every request.

How the key gets set

In middleware, when calling uniformMiddleware, we pass a keys object that includes the user's authentication status. Uniform makes these keys available to components via context.pageState.keys.

// src/middleware.ts (simplified)

const user = await getDisplayUserFromRequest(req);

return uniformMiddleware({
  locale,
  rewriteRequestPath: async ({ url }) => ({
    path: url.pathname,
    keys: {
		  authenticated: String(!!user),  // "true" or "false"
	},
  }),
  // ...
})(req);

The important part is authenticated: String(!!user). This converts the user session into a simple "true" / "false" string that travels with the request through Uniform's rendering pipeline.

How the component reads the key

The AuthContainer component checks context.pageState.keys.authenticated and renders the corresponding slot:

// src/components/AuthContainer.tsx

const AuthContainer: FC<AuthContainerProps> = ({ slots, context }) => {
  return context.pageState.keys?.authenticated === 'true' ? (
    <UniformSlot slot={slots.authorized} />
  ) : (
    <UniformSlot slot={slots.unauthorized} />
  );
};

Why keys instead of quirks?

Uniform quirks are designed for personalization -- they are stored in cookies and visible to the client. Keys are request-scoped metadata that Uniform makes available during server-side rendering but does not expose to the browser. This makes keys the right choice for auth-gated content where you do not want the decision criteria to leak to the client.

Protected routes -- blocking entire paths

Protected routes prevent unauthenticated visitors from reaching a page at all. Unlike quirks and keys which affect what renders on a page, this layer redirects the browser to the login flow before any page code runs.

How accessType maps to an Access Config

Each composition in Uniform can have an accessType parameter. When the webhook rebuilds the protected routes list, it converts each composition's accessType into an access config stored in Edge Config.

The Access Config stores the path and whether to consider itself or children public or private.

For a composition mapped to the path /dashboard:

accessType valuePatterns storedWhat is protected

notSet

(none)

Nothing -- the route is public

public

{ path: '/dashboard', self: 'public', children: 'public' }

Explicitly public, no protection

authenticatedRoute

{ path: '/dashboard', self: 'private', children: 'public' }

The exact path is protected but not descendants

authenticatedChildRoutes

{ path: '/dashboard', self: 'public', children: 'private' }

All descendants, but NOT /dashboard itself

authenticatedRouteAndChildren

{ path: '/dashboard', self: 'private', children: 'private' }

The exact path AND all descendants

How the middleware enforces protection

On every request, middleware reads the user's session and checks the pathname against the stored access configs. The checkProtectedRouteAccess function handles this decision.

image.png

The middleware code that orchestrates this:

// src/middleware.ts (simplified)

const user = await getDisplayUserFromRequest(req);
const { loginRedirectPath, headers } = await checkProtectedRouteAccess(req, user);

if (loginRedirectPath) {
  return NextResponse.redirect(new URL(loginRedirectPath, req.url), { headers });
}

// Continue to Uniform middleware and page rendering...

The redirect includes a returnUrl parameter so the login flow can send the user back to their original destination after authentication.

Supporting the Canvas visual editor

Content authors need to preview protected pages inside Uniform's Canvas editor without being blocked by the login redirect. The auth check must be bypassed for editing sessions, but only when we can verify the request genuinely comes from Canvas.

When an author opens a protected page in Canvas, the site loads inside an iframe. Without special handling, middleware would see an unauthenticated request to a protected route and redirect to the login page -- breaking the editing experience.

Two verification methods

The bypass uses two independent checks. Either one is sufficient to grant access:

  1. Draft mode cookie -- When Canvas initialises, it calls the /api/preview route which sets a __prerender_bypass cookie. This is an HTTP-only cookie managed by Next.js, making it the most secure method. It persists across page navigation within the Canvas session.

  2. Canvas query parameters + secret -- Canvas adds is_incontext_editing_mode=true to the URL. We pair this with a secret parameter that must match the UNIFORM_PREVIEW_SECRET environment variable. Both must be present -- the editing flag alone is not enough.

Why two methods? When navigating between pages inside Canvas, query parameters may not persist on every request. The draft mode cookie covers those cases. The query parameter check covers the initial Canvas load before the cookie is set.

// src/lib/auth/middleware-helpers.ts (simplified)

export async function checkProtectedRouteAccess(req, user) {
  const searchParams = req.nextUrl.searchParams;

  // Method 1: Draft mode cookie (set by /api/preview)
  const hasDraftMode = !!req.cookies.get('__prerender_bypass');

  // Method 2: Canvas editing flag + valid secret
  const isCanvasEditing = searchParams.get('is_incontext_editing_mode') === 'true';
  const hasPreviewSecret = searchParams.get('secret') === process.env.UNIFORM_PREVIEW_SECRET;

  // Either method grants access to all routes
  if (hasDraftMode || (isCanvasEditing && hasPreviewSecret)) {
    return { canAccess: true };
  }

  // Otherwise, enforce normal route protection...
}

Updated state diagram

With the Canvas bypass, the full decision flow looks like this:

image.png

Setup requirements

For this to work you need:

  • A UNIFORM_PREVIEW_SECRET environment variable set in both your hosting environment and the Uniform project's preview configuration

  • The /api/preview route handler configured (see the Uniform App Router documentation for the standard setup)

Webhook -- keeping the route list in sync

The protected routes list must update automatically when CMS authors publish or delete pages. A Uniform webhook triggers this by calling an API route in the application, which fetches the current set of protected compositions and writes them to Edge Config.

Before implementing this, read the Uniform webhooks guide to understand webhook setup, secret verification, and event types.

Webhook handler overview

The webhook API route at /api/webhooks/uniform receives composition events from Uniform. When a relevant event arrives, the handler:

  1. Verifies the request using the uniform-secret header against the UNIFORM_PREVIEW_SECRET environment variable

  2. Checks the event type is a composition publish or delete

  3. Fetches all protected compositions from Uniform and rebuilds the route list

  4. Writes the updated list to Edge Config

The handler responds immediately and processes the update in the background using waitUntil() so Uniform does not time out waiting for a response.

Debouncing: When a CMS author or sync CLI publishes several pages in quick succession, each publish fires a separate webhook. It is recommended to include a debounce to avoid redundant Edge Config writes. The details of this mechanism are beyond the scope of this guide.

Fetching protected routes from Uniform

The core of the webhook is fetchProtectedRoutes(). This queries the Uniform canvas client for all compositions that have an authenticated accessType, then maps each one to an access config. It breaks down into three steps.

1. Define default routes

Some routes may always be protected regardless of what is in the CMS. These act as a fallback if Edge Config is unavailable and as a baseline that the webhook merges CMS-driven routes into.

export const DEFAULT_PROTECTED_ROUTES: string[] = [
  { path: '/my-account', self: 'private', children: 'private' }
];

2. Convert accessType to Access Config

Each composition's accessType value maps to one or more route patterns. This function handles the conversion.

function getAccessRuleByAccessType(path: string, accessType?: string): RouteAccessRule | null {
  switch (accessType) {
    case ACCESS_TYPES.Public:
      return { path, self: 'public', children: 'public' };
    case ACCESS_TYPES.AuthenticatedChildRoutes:
      return { path, self: 'public', children: 'private' };
    case ACCESS_TYPES.AuthenticatedRoute:
      return { path, self: 'private', children: 'public' };
    case ACCESS_TYPES.AuthenticatedRouteAndChildren:
      return { path, self: 'private', children: 'private' };
    default:
      return null;
  }
}

3. Query Uniform and build the full list

fetchProtectedRoutes() queries the canvas client for compositions matching the authenticated access types, looks up each composition's path from the project map, and converts them to route patterns.

const ACCESS_TYPE_PARAMETER = 'accessType';
const ALLOWED_PAGE_TYPES: string[] = [
  'GeneralPurposePage',
  'ProductDetailPage'
];

/*
 * Builds the full route access rule list from Uniform compositions 
 */
export async function fetchAccessConfig(): Promise<RouteAccessRule[]> {
  try {
    const result = (
      await Promise.all(
        ALLOWED_PAGE_TYPES.map(type =>
          getFullCompositionList({
            filters: {
              [`type[eq]`]: type,
              [`parameters.${ACCESS_TYPE_PARAMETER}[def]`]: true,
            },
          })
        )
      )
    ).flat();

    const rulesMap = new Map<string, RouteAccessRule>();

    for (const member of result) {
      const { composition } = member;

      const route = await projectMapClient.getNodes({
        compositionId: composition._id,
      });

      const { accessType } = getAccessType(composition);

      for (const node of route.nodes ?? []) {
        const pathname = node.path;

        if (typeof pathname !== 'string') {
          continue;
        }

        const normalizedPath = normalizePath(pathname);
        const rule = getAccessRuleByAccessType(normalizedPath, accessType);

        if (!rule) {
          continue;
        }

        rulesMap.set(normalizedPath, rule);
      }
    }

    // Include any default rules as long as they don't already exist
    for (const rule of DEFAULT_ACCESS_CONFIG) {
      const normalizedPath = normalizePath(rule.path);
      if (!rulesMap.has(normalizedPath)) {
        rulesMap.set(normalizedPath, rule);
      }
    }

    return Array.from(rulesMap.values());
  } catch (error) {
    console.error('Error fetching route access rules:', error);
    return DEFAULT_ACCESS_CONFIG;
  }
}

The query uses two filters: type[eq] restricts to a specific composition type, and parameters.accessType[def] limits results to compositions with an access type set. For each matching composition, projectMapClient.getNodes() resolves the composition ID to its URL path in the project map.

The composition type filter is page-type specific. The type[eq] filter targets a single composition type (GeneralPurposePage in this example). If your project has multiple composition types that support the accessType parameter, you must run the same query for each type and merge the results. The Uniform canvas client does not support querying across composition types in a single call