Recipe: SEO

Developer preview

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

Goal: Uniform authors set the SEO data of each page. Next.js writes it into the <head> of the page. A sitemap lists the pages of the project map.

You will make these files:

FileJob
lib/uniform/getRoute.tsResolves the route one time for each request.
lib/uniform/getUniformMetadata.tsChanges the composition parameters into a Next.js Metadata object.
app/uniform/[code]/page.tsxAdds generateMetadata to the composition route.
app/sitemap.tsMakes /sitemap.xml from the project map.
  • An app with the composition route app/uniform/[code]/page.tsx. Refer to Add the composition route.

  • The package @uniformdev/canvas, for the constant CANVAS_PUBLISHED_STATE and the helper isAssetParamValue.

  • These parameters on the composition type of your pages, for example page:

    Parameter IDType
    metaTitleText
    metaDescriptionText
    ogImageAsset
  • The environment variable NEXT_PUBLIC_SITE_URL with the public URL of the site, for example https://www.example.com. The code uses it for the canonical URLs and for the sitemap.

generateMetadata and the page both need the route. Wrap the resolver in React cache. Then the two calls in one request share one result:

lib/uniform/getRoute.ts

import { resolveRouteFromCode } from "@uniformdev/next-app-router"; import { cache } from "react"; // One Route API call for each code in a request. // generateMetadata and the page share the result. export const getRoute = cache((code: string) => resolveRouteFromCode({ code }));

The function takes the code as a string. React cache compares the arguments by identity, so a new object on each call does not find the cached result.

lib/uniform/getUniformMetadata.ts

import { CANVAS_PUBLISHED_STATE, isAssetParamValue } from "@uniformdev/canvas"; import type { ResolvedComposition } from "@uniformdev/next-app-router"; import type { Metadata } from "next"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; export function getUniformMetadata({ route, pageState }: ResolvedComposition): Metadata { const parameters = route.compositionApiResponse.composition.parameters ?? {}; const text = (name: string) => { const value = parameters[name]?.value; return typeof value === "string" && value ? value : undefined; }; const image = parameters.ogImage?.value; const imageUrl = isAssetParamValue(image) ? image[0]?.fields.url.value : undefined; const title = text("metaTitle") ?? route.compositionApiResponse.composition._name; const description = text("metaDescription"); // The route path without the query string, for example "/about". const path = pageState.routePath.split("?")[0]; return { title, description, alternates: { canonical: new URL(path, siteUrl).toString() }, openGraph: { title, description, images: imageUrl ? [{ url: imageUrl }] : undefined, }, // Do not index draft or editor content. robots: pageState.compositionState === CANVAS_PUBLISHED_STATE ? undefined : { index: false, follow: false }, }; }
  • Parameters: the composition parameters are in result.route.compositionApiResponse.composition.parameters. Each parameter has a value. A parameter is undefined when the author did not fill it in.
  • Title: the code uses metaTitle. When metaTitle is empty, it uses the name of the composition.
  • Open Graph image: an asset parameter contains a list of assets. The code uses the URL of the first asset.
  • Canonical URL: alternates.canonical adds <link rel="canonical">. The code uses the route path of the page state, without the query string.
  • Robots: pageState.compositionState tells you the state of the content. For draft and editor content, the code adds noindex, nofollow. Only a browser with the draft mode cookie sees draft content, so this rule is an extra safety step.

app/uniform/[code]/page.tsx

import { requireComposition, UniformComposition, type UniformPageParameters, } from "@uniformdev/next-app-router"; import type { Metadata } from "next"; import { resolveComponent } from "@/components/resolveComponent"; import { getRoute } from "@/lib/uniform/getRoute"; import { getUniformMetadata } from "@/lib/uniform/getUniformMetadata"; export const generateStaticParams = async () => []; export async function generateMetadata(props: UniformPageParameters): Promise<Metadata> { const { code } = await props.params; // Applies a Uniform redirect, or calls notFound() when there is no route. const result = requireComposition(await getRoute(code)); return getUniformMetadata(result); } export default async function UniformPage(props: UniformPageParameters) { const { code } = await props.params; return ( <UniformComposition code={code} resolveComponent={resolveComponent} resolveRoute={({ code }) => getRoute(code)} /> ); }
  • params is a promise in Next.js 16. Use await before you read code.
  • The resolver result can be a composition, a redirect or no route. requireComposition returns the composition. For a redirect, it calls redirect() or permanentRedirect(). For no route, it calls notFound(). Next.js permits these functions in generateMetadata.
  • resolveRoute tells UniformComposition to use the same cached function. Thus the page and generateMetadata use one result.

app/sitemap.ts

import { getProjectMapClient } from "@uniformdev/next-app-router"; import type { MetadataRoute } from "next"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const { nodes } = await getProjectMapClient({ // Get the project map again after one hour. cache: { type: "revalidate", interval: 3600 }, }).getNodes({}); return (nodes ?? []) // Keep nodes that have a composition. .filter((node) => node.type === "composition" && node.compositionId) // Skip dynamic nodes, for example /products/:slug. .filter((node) => !node.path.includes(":") && !node.path.includes("*")) .map((node) => ({ url: new URL(node.path, siteUrl).toString() })); }
  • The project map client sends no cache tags. Thus a Uniform webhook does not update the sitemap. The revalidate cache mode gets the project map again after the interval.
  • A dynamic node, for example /products/:slug, is a pattern for many URLs. The sitemap cannot list them from the project map. Add these URLs from your own data source.
  • The example gets all nodes in one request. For a large project map, use the limit and offset options of getNodes.
  • Make sure that the middleware does not process /sitemap.xml. The matcher in Add the middleware excludes sitemap.xml and robots.txt.
  1. The middleware writes the route path and the composition state into the code.
  2. generateMetadata resolves the route from the code. React cache keeps the result for the request.
  3. The page renders UniformComposition, which gets the same result from getRoute.
  4. Next.js adds the metadata tags to the <head> of the page.

The Uniform resolvers also use the Next.js data cache:

  • Published content: the Route API request uses force-cache. The data cache keeps the response until a webhook revalidates its cache tags. Refer to Caching.
  • Draft content, editor content, and next dev: the request uses no-cache. Each request goes to Uniform.
  • Locales and path rewrites: the canonical URL uses pageState.routePath. If rewriteRequestPath in the middleware changes the path, routePath is the project map path, not the public URL. Then make the canonical URL from your public URL.
  • Query strings: the code removes the query string from the canonical URL. If a query string makes a different page, add it again.
  • Metadata after the first response: Next.js can send generateMetadata results after the first part of the page. For bots that do not run JavaScript, Next.js waits for the metadata. Refer to the Next.js documentation for generateMetadata.
  • Ordinary Next.js routes: for a page that resolves its own route, call getUniformMetadata only for a composition result. Refer to Add Uniform regions to Next.js pages.