Recipe: add Uniform regions to Next.js pages

Developer preview

This feature is in developer preview. Use with caution as it may change unexpectedly. For more information, contact us.

Goal: your app has its pages in Next.js already. The pages stay ordinary Next.js routes, with their layout in JSX. Uniform manages only some named regions in each page, for example a hero region and a banner region. Authors edit these regions in Canvas.

Use this recipe to add Uniform to an app one page at a time. If Uniform must own the full layout of the page, use the app/uniform/[code] route. Refer to Add the composition route.

A page has two trees:

  • The page tree: Next.js renders it from your JSX. It contains UniformRegion elements, each with a name.
  • The composition tree: Uniform delivers it. Its root component has one slot for each region.

The slot name joins one region to one slot:

  • The root component of the composition is only a holder for slots. It has no markup of its own.
  • The region sets where the content goes. The slot sets what content goes there.
  • A region can be at any depth of the page tree. The layout components between the page and the region do not pass slot data.
  • A Next.js 16 App Router app with the Uniform SDK installed and configured. Refer to Next.js App Router SDK.
  • The page state helper lib/uniform/getPageState.ts. Copy it from Use ordinary Next.js routes.
  • In Uniform:
    • A composition type for the pages, for example page. Give it one slot for each region, for example hero and related. Write down the public IDs of the slots.
    • The component types that authors can put in the slots.
    • A project map node for each Next.js route that has regions. For example, the node /products/:slug for app/products/[slug]/page.tsx.
    • A composition of the page type on each node.

The regions get their slots from a per-request store. Thus the layout does not pass the slots down:

lib/uniform/regions.ts

import { serverContext } from "@uniformdev/next-app-router"; import type { SlotDefinition } from "@uniformdev/next-app-router-shared"; // The slots of the page composition, for one request. // UniformRegions sets them. Each UniformRegion reads them. export const [getRegionSlots, setRegionSlots] = serverContext<Record<string, SlotDefinition | undefined>>({});

serverContext uses React cache. The value is for one server request only, and only server components can read it.

UniformRegions connects a page to its composition. The page JSX is its children:

components/uniform/UniformRegions.tsx

import { UniformContext, UniformResolvedComposition, type ResolveComponentFunction, type ResolvedRouteResult, type UniformResolvedCompositionProps, } from "@uniformdev/next-app-router"; import type { SlotDefinition } from "@uniformdev/next-app-router-shared"; import type { ReactNode } from "react"; import { resolveComponent } from "@/components/resolveComponent"; import { setRegionSlots } from "@/lib/uniform/regions"; type UniformRegionsProps = { result: ResolvedRouteResult; children: ReactNode; resolveEmptyPlaceholder?: UniformResolvedCompositionProps["resolveEmptyPlaceholder"]; }; export function UniformRegions({ result, children, resolveEmptyPlaceholder }: UniformRegionsProps) { // No composition for this path: render the page with empty regions. if (result.route?.type !== "composition") { return <UniformContext result={result}>{children}</UniformContext>; } const rootId = result.route.compositionApiResponse.composition._id; // Replaces the root component of the composition. const Root = ({ slots }: { slots?: Record<string, SlotDefinition> }) => { setRegionSlots(slots ?? {}); // 1. Give the slots to the regions. return children; // 2. Render the page. }; const resolveRegionComponent: ResolveComponentFunction = (options) => options.component._id === rootId ? { component: Root } : resolveComponent(options); return ( <UniformContext result={result}> <UniformResolvedComposition result={result} resolveComponent={resolveRegionComponent} resolveEmptyPlaceholder={resolveEmptyPlaceholder} /> </UniformContext> ); }

The composition renders one time. Root replaces its root component:

  1. The SDK renders the slots of the root, and gives them to Root as the slots prop.
  2. Root puts the slots in the store.
  3. Root returns the page JSX. Each UniformRegion in the JSX then reads its slot from the store.

Render the composition one time

Do not render one UniformResolvedComposition for each region. In Canvas, each render adds editing markers for the root component. With more than one set of markers, Canvas cannot select the components correctly.

note

Do not name the wrapper UniformComposition. The SDK exports a component with that name.

components/uniform/UniformRegion.tsx

import { UniformSlot } from "@uniformdev/next-app-router/component"; import { getRegionSlots } from "@/lib/uniform/regions"; type UniformRegionProps = { // The public ID of a slot on the page composition. name: string; className?: string; }; export function UniformRegion({ name, className }: UniformRegionProps) { const slot = getRegionSlots()?.[name]; return <div className={className}>{slot ? <UniformSlot slot={slot} /> : null}</div>; }

UniformRegion is a server component. Put it only below UniformRegions in the tree, so that the store has the slots before the region renders.

lib/uniform/resolvePageRoute.ts

import { requireComposition, resolveRouteFromPath } from "@uniformdev/next-app-router"; import { getPageState, type SearchParams } from "@/lib/uniform/getPageState"; export async function resolvePageRoute(path: string, searchParams: SearchParams) { const pageState = await getPageState(path, searchParams); const result = await resolveRouteFromPath({ path: pageState.routePath, pageState }); // Apply Uniform redirects. Keep the page when Uniform has no composition. if (result.route?.type === "redirect") { requireComposition(result); } return result; }

getPageState makes the page state for the request:

  • Published: it does not read searchParams, so the route stays static. It sets edgeMode: true for the edge middleware.
  • Draft: it reads searchParams to find the editor state and the release.

The page does not call notFound() when Uniform has no composition. The page renders, and its regions stay empty. If the page must be a 404 without a composition, call notFound() from next/navigation in the page.

Wrap the JSX of the page in UniformRegions. Put a UniformRegion where each region goes:

app/products/[slug]/page.tsx

import { UniformRegion } from "@/components/uniform/UniformRegion"; import { UniformRegions } from "@/components/uniform/UniformRegions"; import type { SearchParams } from "@/lib/uniform/getPageState"; import { resolvePageRoute } from "@/lib/uniform/resolvePageRoute"; type Props = { params: Promise<{ slug: string }>; searchParams: SearchParams }; // Render each page on its first visit, then serve it from the cache. export const generateStaticParams = async () => []; export default async function ProductPage({ params, searchParams }: Props) { const { slug } = await params; const result = await resolvePageRoute(`/products/${slug}`, searchParams); return ( <UniformRegions result={result}> <header>Site header</header> <main> <h1>Product {slug}</h1> <UniformRegion name="hero" /> <p>Product details that Next.js owns.</p> <UniformRegion name="related" /> </main> <footer>Site footer</footer> </UniformRegions> ); }
  • The name of each region must be the same as the public ID of a slot. A different name gives an empty region, with no error.

  • The path that you give to resolvePageRoute must match a project map node. Here, /products/shoes matches the node /products/:slug.

  • To set the page title from Uniform, refer to SEO. Call getUniformMetadata only for a composition result:

    app/products/[slug]/page.tsx

    import type { Metadata } from "next"; import { getUniformMetadata } from "@/lib/uniform/getUniformMetadata"; export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> { const { slug } = await params; const { pageState, route } = await resolvePageRoute(`/products/${slug}`, searchParams); return route?.type === "composition" ? getUniformMetadata({ pageState, route }) : { title: `Product ${slug}` }; }

resolveComponent maps only the components that authors can put in the slots. Do not add the page type (page) or page parts, for example the header and the footer:

  • UniformRegions replaces the root component. Thus resolveComponent does not get the page type.
  • If the header is in the map, an author can put a second header into a region.

The middleware must rewrite each request to its own URL, not to /uniform/[code]. Then Next.js uses your routes.

  • Edge mode: use handleUniformEdgeRoute. Make the manifest provider one time, at module scope, so that it keeps the manifest between requests. For the full code, refer to Configure the middleware and the preview.

  • Lite mode: use handleUniformRoute, and remove edgeMode: true from getPageState:

    middleware.ts

    import { handleUniformRoute } from "@uniformdev/next-app-router/middleware"; import type { NextRequest } from "next/server"; export default function middleware(request: NextRequest) { return handleUniformRoute({ request, // Rewrite each request to its own URL, so Next.js uses your routes. // An empty string keeps the default rewrite of the playground. rewriteDestinationPath: async ({ source }) => source === "route" ? `${request.nextUrl.pathname}${request.nextUrl.search}` : "", }); } export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"], runtime: "experimental-edge", };

The page makes its own page state. Thus the page does not read the code of the middleware. In lite mode, the middleware still sends the quirks from the Vercel geolocation headers to the browser.

app/api/preview/route.ts

import { createPreviewGETRouteHandler, createPreviewOPTIONSRouteHandler, createPreviewPOSTRouteHandler, } from "@uniformdev/next-app-router/handler"; export const GET = createPreviewGETRouteHandler({ // Canvas sends the project map path. Return the URL of your Next.js route. resolveFullPath: ({ path, slug }) => path ?? slug, }); export const POST = createPreviewPOSTRouteHandler(); export const OPTIONS = createPreviewOPTIONSRouteHandler();
  • Canvas sends the path of the project map node. If your URLs are different from the project map paths, change the path in resolveFullPath. Refer to Change the preview path.
  • Each project map node that has a composition must have a Next.js route for its path. Without a route, the path gives a 404, also in Canvas preview.

Canvas shows composition patterns on the playground route. A pattern of the page type has no component of its own, and resolveComponent does not know the page type. Thus render it through UniformRegions, with one region for each slot:

app/playground/[code]/page.tsx

import { requireComposition, resolvePlaygroundRoute, UniformPlayground, type PlaygroundParameters, } from "@uniformdev/next-app-router"; import { draftMode } from "next/headers"; import { resolveComponent } from "@/components/resolveComponent"; import { UniformRegion } from "@/components/uniform/UniformRegion"; import { UniformRegions } from "@/components/uniform/UniformRegions"; export default async function PlaygroundPage({ params }: PlaygroundParameters) { if (!(await draftMode()).isEnabled) { return <h1>Playground is only available in draft mode</h1>; } const { code } = await params; const result = requireComposition(await resolvePlaygroundRoute({ code })); const composition = result.route.compositionApiResponse.composition; // A composition pattern of the page type: show one region for each slot. if (composition.type === "page") { return ( <UniformRegions result={result}> {Object.keys(composition.slots ?? {}).map((name) => ( <UniformRegion key={name} name={name} /> ))} </UniformRegions> ); } // Other patterns: the default playground. return <UniformPlayground result={result} resolveComponent={resolveComponent} />; }

Set playgroundPath: "/playground" in uniform.server.config.ts. Without this branch, a page pattern shows the fallback of your resolveComponent, for example "Component not found: page".

  1. The middleware rewrites the request to its own URL. Next.js renders your route.
  2. The page makes the page state, and resolves the route from the path with resolveRouteFromPath.
  3. A Uniform redirect gives a redirect. No composition gives the page with empty regions.
  4. UniformRegions renders UniformContext and the composition one time. The root component puts the slots in the store, and returns the page JSX.
  5. Each UniformRegion reads its slot from the store, and renders the slot items.
  6. In Canvas, the regions show draft content with editing markers.
  • Authors cannot change the layout. They can fill only the regions that you put in the page.
  • The slot names are a contract. A region name must be the same as a slot public ID. A mismatch gives an empty region, with no error.
  • One composition for each page. For content from a second composition, for example a shared footer, refer to Render more than one composition.
  • Server components only. UniformRegions and UniformRegion use serverContext. A client component cannot read the slots. It can render a UniformRegion that a server component gives to it as children.
  • Two route calls. generateMetadata and the page both resolve the route. For published content, Next.js can serve the second call from its data cache.