npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@prismetic/seo-utils

v3.0.0

Published

Next.js metadata, JSON-LD and sitemap noIndex helpers for Strapi-backed sites — React 19 / Next 16

Readme

@prismetic/seo-utils

Built for Prismetic's ITE and Red Sea Strapi-backed Next.js sites. The CMS field names, content-type uids and route shapes below are theirs; the policies and helpers are generic.

Next.js App Router metadata, JSON-LD and next-sitemap helpers for the Strapi-backed ITE sites. Since 2.3 the sitemap's noindex exclusions are read from the exported HTML, so a page's own metadata is the only place indexability is declared. One implementation replaces the per-repo lib/seo.js, lib/jsonLd.tsx, lib/sitemapNoIndex.js and (Mosbuild) lib/generateLlmsTxt.js; those files are deleted and the repos use the package directly.

Install

npm install @prismetic/seo-utils --save-exact   # exact pin, no caret

Five entries. The first two are the single-language ITE pair; localized (2.4) wraps createSeo for a site with more than one locale, and the bilingual pair (2.1) serves sites whose every URL carries a locale segment.

| Entry | For | Imports React | Runs | | --- | --- | --- | --- | | @prismetic/seo-utils | page metadata and JSON-LD | yes | build + request | | @prismetic/seo-utils/sitemap | next-sitemap.config.js — noIndex exclusions, hreflang alternates, robots.txt Content-Signal, llms.txt | no | Node only, never bundled | | @prismetic/seo-utils/localized | page metadata per locale, each with an optional URL prefix (/about/, /cn/about/) | no | build + request | | @prismetic/seo-utils/bilingual | page metadata for localized URLs (/en/…, /ar/…) | no | build + request | | @prismetic/seo-utils/bilingual/sitemap | sitemap helpers for those sites | no | Node only |

Which entry for page metadata:

  • . — a single language.
  • ./localized — several locales, each with an optional prefix. Mosbuild: English unprefixed (/about/), Chinese under /cn/about/.
  • ./bilingual — every locale prefixed, the default included. Red Sea: /en/dining/, /ar/dining/.

Nothing in ./localized or the bilingual entries is reachable from . or ./sitemap, which is how a single-language site is guaranteed the output it had in 2.0.

Requirements

| | | | --- | --- | | Module format | ESM. CommonJS files such as lib/siteConfig.js and next-sitemap.config.js load it with a plain require() through Node's require(esm) | | Node | 20.19+ / 22.12+ for that require(esm) path; older versions cannot load it from CJS | | Peer | react >=18 — accurate as declared; nothing here uses a React 19-only API | | Next.js | not a peer dep and never imported. SeoMetadata is structurally assignable to Next's Metadata | | "use client" | not needed anywhere — JsonLd is a server component and everything else is a plain function | | Tested against | React 19 / Next 16 |

The ./sitemap and ./bilingual/sitemap entries are deliberately free of React and of top-level await so require(esm) keeps working from a CJS config.

Using this outside the ITE fleet? See Portability for what is generic, what assumes Strapi, and what is ITE-specific.

Quick start

Bind generateSEOMetadata once next to the site identity:

// lib/siteConfig.js
const { createSeo } = require("@prismetic/seo-utils");

const SITE_URL = "https://expopharmtech.com";
const SITE_NAME = "Pharmtech & Ingredients";

module.exports = {
  SITE_URL,
  SITE_NAME,
  generateSEOMetadata: createSeo({ siteUrl: SITE_URL, siteName: SITE_NAME })
    .generateSEOMetadata,
};

Then use it in any page:

import { generateSEOMetadata } from "@/lib/siteConfig.js";

export async function generateMetadata() {
  return generateSEOMetadata(seoData, "about", pageData);
}

Real-world usage

With the strict policies enabled and the Open Graph locale set:

module.exports = {
  SITE_URL,
  SITE_NAME,
  generateSEOMetadata: createSeo({
    siteUrl: SITE_URL,
    siteName: SITE_NAME,
    locale: "ru_RU",                         // Open Graph locale, defaults to "en_US"
    canonicalPolicy: "strict-same-origin",   // default "passthrough"
    noIndexPolicy: "strict",                 // default "truthy"
  }).generateSEOMetadata,
};

Both policies default to 2.0 behaviour, so a site that does not pass them is unaffected — that is what the 109 original tests and mainEntryUnchanged.test.ts pin. See ITE: enabling the strict checks before turning them on.


API reference

Entry . — page metadata and JSON-LD

createSeo(config)

createSeo(config: SeoConfig): { generateSEOMetadata: GenerateSEOMetadata }

| config field | type | default | | | --- | --- | --- | --- | | siteUrl | string | — | site origin, e.g. "https://expopharmtech.com". A trailing slash is stripped | | siteName | string | — | the event's name. Open Graph site name, and last-resort page title | | locale | string | "en_US" | Open Graph locale | | canonicalPolicy | "passthrough" \| "strict-same-origin" | "passthrough" | how far to trust a CMS canonicalURL | | noIndexPolicy | "truthy" \| "strict" | "truthy" | how to read seo.noIndex |

Returns an object with one member. Binding it once per site is the intended use; the returned function closes over the config.

generateSEOMetadata(seoData, path?, pageData?)

type GenerateSEOMetadata = (
  seoData: CmsSeo | null | undefined,
  path?: string | null,
  pageData?: CmsPageData,
) => SeoMetadata;

| param | type | | | --- | --- | --- | | seoData | CmsSeo \| null \| undefined | the page's Strapi seo component | | path | string \| null | the page's path below the origin, e.g. "about" | | pageData | CmsPageData | the page record itself, for the title/description fallback chains |

Returns a SeoMetadata object — title, description, keywords, canonical, Open Graph, Twitter and robots. See Page metadata for the fallback chains and the rules that govern each field.

<JsonLd />

<JsonLd seo={seoData} />

| prop | type | | | --- | --- | --- | | seo | CmsSeoJsonLd | anything with a JSON_LD field; null/undefined renders nothing |

Renders each object in the CMS seo.JSON_LD field (a JSON string, an object or an array) as an application/ld+json script, and nothing when the field is empty or invalid.

A plain <script> tag, not next/script, so the App Router can hoist it server-side. < is escaped to < in the payload so a string value can never close the script tag early.

getStructuredDataScriptInnerHtmls(jsonLdField)

getStructuredDataScriptInnerHtmls(jsonLdField: unknown): string[]

The escaped JSON strings JsonLd would render, for custom rendering. Never throws:

| input | result | | --- | --- | | a JSON string (BOM and whitespace tolerated) | parsed, then treated as below | | an array | every non-null object element | | an object | one element | | a Date, a primitive, invalid JSON, null | [] |

summarise(text, max?)

summarise(text: unknown, max = 150): string | null

Collapses a rich-text summary into something usable as a meta description. Returns null for a non-string or a value that cleans to nothing.

In order: <style> and <script> blocks are removed including their inner text, then all tags, then the six common entities (&nbsp; &amp; &lt; &gt; &quot; &#39;), then whitespace is collapsed and trimmed. If the result is longer than max it is cut at the last space and given an ellipsis. The default is 150 (160 before 2.2), so a cut description, ellipsis included, stays under the 155-character and 985-pixel marks Screaming Frog flags.

summarise("<p>Hello&nbsp;world</p>");  // "Hello world"
summarise("   ");                      // null
summarise(42);                         // null

The <style>/<script> rule matters: a page whose Content embeds a form would otherwise describe itself with that form's CSS.

cleanHeaderTitle(value)

cleanHeaderTitle(value: unknown): string | null

Removes a // separator from a page heading, without touching :// in a URL, then collapses whitespace and tidies space before punctuation. Returns null for a non-string or an empty result.

cleanHeaderTitle("Industry Insights // Hub");  // "Industry Insights Hub"

No truncation and no brand suffix — Header.Title is used as the editor wrote it.

Types

interface SeoConfig {
  siteUrl: string;
  siteName: string;
  locale?: string;
  canonicalPolicy?: "passthrough" | "strict-same-origin";
  noIndexPolicy?: "truthy" | "strict";
}

/** The Strapi `seo` component as the sites query it. */
interface CmsSeo {
  metaTitle?: string | null;
  metaDescription?: string | null;
  keywords?: string | string[] | null;
  noIndex?: unknown;              // noIndexPolicy decides how it is read
  canonicalURL?: string | null;
  metaImage?: { url?: string | null } | null;
  [key: string]: unknown;
}

/** The page record. Shapes differ per content type, so this stays loose on purpose. */
type CmsPageData = Record<string, any> | null | undefined;

/** Structurally assignable to Next's `Metadata`. */
interface SeoMetadata {
  title: { absolute: string };
  description?: string;           // omitted when the page has no summary of its own
  keywords: string | string[] | null | undefined;
  alternates: { canonical: string };
  openGraph: {
    title: string;
    description?: string;
    url: string;
    siteName: string;
    locale: string;
    type: "website";
    images?: Array<{ url: string; width: number; height: number; alt: string }>;
  };
  twitter: {
    card: "summary_large_image";
    title: string;
    description?: string;
    images?: string[];
  };
  robots: { index: boolean; follow: boolean; googleBot: { index: boolean; follow: boolean } };
}

Also exported: GenerateSEOMetadata, CmsSeoJsonLd.

Not exported from this entry, though they exist in the source: CanonicalPolicy, NoIndexPolicy, SeoRobots, parseNoIndex and NoIndexVerdict. Pass the policy values as string literals; parseNoIndex is reachable from the two bilingual entries.


Entry ./sitemap — next-sitemap helpers

Node-only. Never bundled.

createSitemapNoIndexFromBuild(options?)

createSitemapNoIndexFromBuild(options?: BuildNoIndexOptions): BuildNoIndex

| option | type | default | | | --- | --- | --- | --- | | outDir | string | the transform's config.outDir, then "out" | where next build exported the pages | | excludeRedirects | boolean | true | leave <meta http-equiv="refresh"> pages out | | readFile | (file) => string \| null | fs.readFileSync | injectable for tests | | onExclude | (path, reason) => void | one console.log line | called once per excluded path | | cache | boolean | false | read each exported file once per instance, shared by inspect, shouldExclude and alternateRefs |

Returns:

interface BuildNoIndex {
  /** The exported page behind a sitemap path, or null when no file exists for it. */
  inspect(path: string, config?: { outDir?: string | null }): ExportedPage | null;
  /** True when the exported page must stay out of the sitemap. */
  shouldExclude(path: string, config?: { outDir?: string | null }): boolean;
  /** The page's own hreflang links, minus any whose target is not in the sitemap. */
  alternateRefs(path: string, config?: { outDir?: string | null; siteUrl?: string | null }): SitemapAlternateRef[];
}
const {
  contentSignalRobotsTxt,
  createSitemapNoIndexFromBuild,
} = require("@prismetic/seo-utils/sitemap");
const buildNoIndex = createSitemapNoIndexFromBuild();

module.exports = {
  siteUrl: SITE_URL,
  outDir: "out",
  transform: async (config, path) => {
    if (buildNoIndex.shouldExclude(path, config)) return null;
    return {
      loc: path,
      changefreq: config.changefreq,
      priority: config.priority,
      lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
      alternateRefs: config.alternateRefs ?? [],
    };
  },
};

shouldExclude reads the HTML that next build exported for the path — out/a/b/index.html for /a/b/, then out/a/b.html — and returns true when its <head> says the page is not one to list:

| the page renders | reason | why | | --- | --- | --- | | <meta name="robots" content="noindex…"> or "none" | noindex | robots: { index: false } in its metadata, or notFound() — Next adds the tag itself | | <meta http-equiv="refresh"> | redirect | a StaticRedirect; a static export cannot emit a 3xx |

That is every page, CMS-driven or code-owned, with nothing to keep in sync: a page's own metadata is the one place its indexability is declared, and the sitemap now reads it from the same file the crawler will. A missing file is kept in the sitemap and reported with console.error — the sitemap step must not fail a build. A page with no robots meta at all is kept: the page, not the sitemap config, has to say noindex. See Sitemap: what the exported page says.

alternateRefs(path, config) copies the page's own hreflang links — each <link rel="alternate" hreflang href> in its exported <head>, in document order — into next-sitemap's alternateRefs, so the sitemap says what the page says. For a site on ./localized:

const buildNoIndex = createSitemapNoIndexFromBuild({ cache: true });

module.exports = {
  siteUrl: SITE_URL,
  outDir: "out",
  transform: async (config, path) => {
    if (buildNoIndex.shouldExclude(path, config)) return null;
    return {
      loc: path,
      changefreq: config.changefreq,
      priority: config.priority,
      lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
      alternateRefs: buildNoIndex.alternateRefs(path, config),
    };
  },
};

| the alternate | result | | --- | --- | | on config.siteUrl's origin, its target exported and listable | kept | | on that origin, its target noindex, a redirect (with excludeRedirects) or not exported | dropped, one console.error naming the page and the target | | on another origin, or config.siteUrl absent | kept unverified | | not an absolute URL | dropped, one console.error |

When nothing is left that points at a page other than path itself, the result is []. A page with no exported file gets [] without a log; shouldExclude has reported it. Checking a target never calls onExclude.

A target missing from the sitemap's own <loc> set makes the link non-reciprocal, and Google discards the whole cluster — hence the drop. next-sitemap's global alternateRefs option cannot do this: it appends the page path to every href, which breaks prefixed paths.

cache: true makes the noindex check, the page's alternates and every target check share one read per file. Only the <head> is kept. Without it every call reads the disk, as in 2.3.

normalizeSitemapPath(p)

normalizeSitemapPath(p: unknown): string

The canonical form used as the set key: stringified, trimmed, trailing slashes stripped, a leading slash added. null, "" and "/" all become "/".

normalizeSitemapPath("about/");   // "/about"
normalizeSitemapPath("/about");   // "/about"
normalizeSitemapPath(null);       // "/"

Both sides of a comparison must go through it, which is why ./bilingual/sitemap's createLocaleSitemapNoIndex hands it back next to its set. A hand-written transform that keeps its own exclusion set uses it the same way.

contentSignalRobotsTxt(signal?)

contentSignalRobotsTxt(signal?: string): TransformRobotsTxt

| param | default | | --- | --- | | signal | DEFAULT_CONTENT_SIGNAL = "search=yes, ai-input=yes, ai-train=no" |

Returns a next-sitemap transformRobotsTxt hook:

module.exports = {
  generateRobotsTxt: true,
  robotsTxtOptions: {
    transformRobotsTxt: contentSignalRobotsTxt(),
  },
};

Inserts Content-Signal: <signal> directly under the User-agent: * group (contentsignals.org: stay in search and AI-assistant answers, let assistants read pages to answer users, withhold bulk model training). A robots.txt without a User-agent: * group is returned unchanged.

generateLlmsTxt(options)

generateLlmsTxt(options: LlmsTxtOptions): Promise<void>

| option | type | | | --- | --- | --- | | siteUrl | string | site origin, e.g. "https://mosbuildexpo.com". A trailing slash is tolerated, a path is not | | outDir | string | the static export, e.g. "out". It must already hold index.html; llms.txt is written into it | | strapiGraphqlUrl | string | GraphQL endpoint | | homepageType | string | GraphQL root of the homepage single type, e.g. "mosBuildHomepage" | | navBarType | string | GraphQL root of the navbar single type, e.g. "mosBuildNavBar" | | navText | "component" \| "string" | how this site's navbar NavText (event date and location) is shaped; omit to leave it out | | fetch | PostFetchLike | defaults to the global fetch |

Called inside next-sitemap's additionalPaths hook, which runs after next build has exported the site:

const { generateLlmsTxt } = require("@prismetic/seo-utils/sitemap");

module.exports = {
  additionalPaths: async (config) => {
    await generateLlmsTxt({
      siteUrl: config.siteUrl,
      outDir: config.outDir ?? "out",
      strapiGraphqlUrl: "https://ite-cms.prismetic.com/graphql",
      homepageType: "mosBuildHomepage",
      navBarType: "mosBuildNavBar",
    });
    return [];
  },
};

Writes <outDir>/llms.txt (llmstxt.org) from the homepage SEO title and description and the links editors put in the CMS navbar, each checked against the export:

# {metaTitle}

> {metaDescription}

## Pages

- [{text}]({url})
- [{text}]({url})

LF line endings and one trailing newline. A blank description leaves out the blockquote and its blank line. When no link survives, ## Pages is left out and one console.error says so; the file is still written. Every whitespace run in the title, the description and a link's text becomes one space. In link text, \, [ and ] are escaped with a backslash, and a link with no text uses its URL as its text. There is no Homepage line: only the navbar's own links appear.

The links are each top-level item's own LinkTo, then the Links[] of its dropdown, in nav order; a dropdown title without a LinkTo yields nothing. Each resolves against siteUrl:

| the link | in llms.txt | | --- | --- | | a page exported as p/index.html | https://site/p/. The slash comes from the export; query and hash are dropped | | a page exported as p.html, or a raw .html file | https://site/p, no slash | | any other exported file, e.g. /files/brochure.pdf | listed as-is, never read | | a page whose head says noindex | dropped, one console.log | | a <meta http-equiv="refresh"> page | followed once: the target's URL replaces the link's, the nav text stays. An external, non-http or noindex target is dropped with console.log; a second redirect, a missing target or a refresh with no URL with console.error | | another origin, http: on an https: site included | dropped, one console.log | | mailto:, tel:, javascript: … | dropped, one console.log | | not in the export, or resolving outside outDir | dropped, one console.error | | a URL already listed | skipped silently; the first in nav order wins |

A listed URL is percent-encoded the way the URL parser writes it (a Cyrillic path included), with ( and ) encoded too so the Markdown link holds.

navText adds the navbar's NavText, the event date and location, as a list under the description. Its shape is per site: 22 ITE sites model it as a component, NavText { Text1 Text2 }, and 6 as a plain string. The site says which; omitted, the query is exactly the 2.x one and no line is written.

await generateLlmsTxt({
  // …the options above
  navText: "component",   // or "string"
});
# {metaTitle}

> {metaDescription}

- 13 - 15 April 2027
- Crocus Expo, Moscow

## Pages

- [{text}]({url})

Text1 and Text2, or the string, are split on line breaks; each line is trimmed with its whitespace collapsed, empty lines are dropped, and nothing is escaped or labelled. The lines follow the description, or the title when there is none, and stay when no link survives. When the site opts in and no line is left, one console.error names <navBarType>.NavText and the file is written without them. A navText that does not match the site's schema makes Strapi reject the query, which fails the build.

Throws. It rejects with an Error whose message starts with [llms.txt] and names the cause when:

  • siteUrl is not an absolute http(s) origin;
  • navText is set to anything but "component" or "string";
  • <outDir>/index.html does not exist, checked before any request is sent;
  • the request fails, or the response has ok: false (the message includes its status when present and any GraphQL error messages in its body);
  • the body is not JSON;
  • the body has a non-empty GraphQL errors array, even next to data;
  • <homepageType>.seo.metaTitle is blank, or the homepage record is missing;
  • <navBarType>.Data is not a list;
  • a file in the export cannot be read, or llms.txt cannot be written.

A rejection fails next-sitemap, and with it the build. See Limits.

Types

// The fetch option of ./bilingual/sitemap's createLocaleSitemapNoIndex, re-exported there.
type FetchLike = (url: string) => Promise<{
  ok: boolean;
  json(): Promise<unknown>;
  status?: number;
  text?(): Promise<string>;
}>;

// generateLlmsTxt's fetch option. Absent ok/status ⇒ the response is not classified.
type PostFetchLike = (
  url: string,
  init: { method: "POST"; headers: Record<string, string>; body: string },
) => Promise<{ ok?: boolean; status?: number; json(): Promise<unknown> }>;

type TransformRobotsTxt = (config: unknown, robotsTxt: string) => Promise<string>;

interface ExportedPage {
  robots: string | null;   // content of the first <meta name="robots">, null when absent
  noIndex: boolean;        // any robots meta says noindex or none
  redirect: boolean;       // <meta http-equiv="refresh"> present
}
type BuildExcludeReason = "noindex" | "redirect";

interface ExportedAlternate { hreflang: string; href: string }   // href with &amp; &quot; &#x27; &lt; &gt; decoded, once
interface SitemapAlternateRef { href: string; hreflang: string; hrefIsAbsolute: true }

Also exported: BuildNoIndexOptions, BuildNoIndex, SitemapTransformConfig, LlmsTxtOptions, DEFAULT_CONTENT_SIGNAL, and the three pure pieces of the build reader, inspectExportedHtml(html), inspectExportedAlternates(html) and exportedFilesFor(path, outDir).


Entry ./localized — metadata per locale, optional prefix

createLocalizedSeo(config)

createLocalizedSeo(config: LocalizedSeoConfig): LocalizedSeo

| config field | type | default | | | --- | --- | --- | --- | | siteUrl | string | — | site origin; trailing slash stripped | | siteName | string | — | as createSeo | | canonicalPolicy | "passthrough" \| "strict-same-origin" | "passthrough" | passed to every locale's createSeo | | noIndexPolicy | "truthy" \| "strict" | "truthy" | passed to every locale's createSeo | | defaultLocale | string | — | a key of locales; x-default, and the clamp target | | locales | Record<string, SeoLocale> | — | keyed by the CMS locale code ("zh-CN", case-sensitive) |

| SeoLocale field | type | default | | | --- | --- | --- | --- | | prefix | string | — | one URL segment; "" for none. At most one locale may be unprefixed | | ogLocale | string | — | Open Graph locale | | hreflang | readonly string[] | — | the codes this locale is advertised under, in order | | enabled | boolean | true | computed by the site; the package reads no env var |

One createSeo per locale, built with locale: ogLocale and the two policies. The title and description ladder is createSeo's, so everything under Page metadata holds for every locale.

Throws on a config that cannot produce valid URLs: defaultLocale not in locales, a prefix that is not a string, two locales sharing a prefix, more than one unprefixed locale, a prefix containing /, an hreflang that is not an array of strings, an empty hreflang or a blank code in it, one hreflang code on two locales (compared case-insensitively), or an ogLocale that is not a non-blank string. lib/siteConfig.js is plain JavaScript, so these are checked at runtime.

Other fields on a locale are ignored, so one object can feed this and @prismetic/i18n-utils:

const LOCALES = {
  defaultLocale: "en",
  locales: {
    en:      { prefix: "",   ogLocale: "en_US", hreflang: ["en"],          htmlLang: "en",    label: "EN" },
    "zh-CN": { prefix: "cn", ogLocale: "zh_CN", hreflang: ["zh-CN", "zh"], htmlLang: "zh-CN", label: "中文", enabled: ZH_ENABLED },
  },
};

const seo = createLocalizedSeo({ ...SEO_OPTIONS, ...LOCALES });

Returns { forLocale, localizedRoute, localizedUrl }.

seo.forLocale(locale)

forLocale(locale: string): { generateSEOMetadata: LocalizedGenerateSEOMetadata }

A locale that is not a key of locales is clamped to defaultLocale and logged with console.error, never thrown, as in ./bilingual. Keys are case-sensitive: "zh-cn" is unknown.

generateSEOMetadata(seoData, route?, pageData?, options?) — localized

type LocalizedGenerateSEOMetadata = (
  seoData: CmsSeo | null | undefined,
  route?: string | null,
  pageData?: CmsPageData,
  options?: { alternates?: Record<string, string | null | undefined> },
) => LocalizedSeoMetadata;

| param | | | --- | --- | | seoData, pageData | as the main entry | | route | the path below the locale prefix: "articles/x", "" for the home | | options.alternates | locale key → the route of the same document in that locale. Leave a locale out, or pass null, when it has no published counterpart |

Returns createSeo's result for localizedRoute(route, locale), plus alternates.languages when there is hreflang to emit:

  • only when the current locale is enabled and another enabled locale has a string entry in alternates ("" is a home). Otherwise the key is absent and the result is createSeo's, key for key;
  • in config order: the current locale and its enabled counterparts, each with its hreflang codes in order, then x-default → the default locale's URL when the default locale is among them;
  • every URL ends in /; an unprefixed home is the bare siteUrl + "/".

No openGraph.alternateLocale.

const { generateSEOMetadata } = seo.forLocale("zh-CN");

generateSEOMetadata(article.seo, `articles/${article.Slug}`, article, {
  alternates: alternatesFromLocalizations(article.localizations, (l) => `articles/${l.Slug}`),
});
// canonical  https://mosbuildexpo.com/cn/articles/b/
// languages  en → /articles/a/, zh-CN → /cn/articles/b/, zh → /cn/articles/b/, x-default → /articles/a/

The canonical guard runs first, on a CMS canonicalURL that parses as an absolute URL on the site's own host. Anything else — blank, relative, malformed, off-site — reaches createSeo untouched, and canonicalPolicy decides.

The URL's first path segment names its locale: the one whose prefix equals the whole segment (/cnc-machines/ is not /cn/), otherwise the unprefixed locale. A disabled locale's prefix still counts. The canonical is ignored when it belongs to another locale — a zh page claiming an English URL, or an English page claiming a /cn/ one; Strapi's "Fill in from another locale" copies the English canonical into the translation — or, on a prefixed locale, when a sub-page claims that locale's home (/cn/, /en/). An ignored canonical gives way to the computed per-locale one and is logged once with console.error, naming the value and the page's path. An accepted one is passed on as is.

On the unprefixed locale the site root stays createSeo's job, exactly as on the main entry, so an English page that claims no /cn/ URL renders and logs what createSeo alone would.

seo.localizedRoute(route, locale) / seo.localizedUrl(route, locale)

seo.localizedRoute("articles/x", "zh-CN"); // "cn/articles/x"
seo.localizedUrl("articles/x", "zh-CN");   // "https://mosbuildexpo.com/cn/articles/x/"
seo.localizedUrl("", "en");                // "https://mosbuildexpo.com/"

A route is trimmed and loses its wrapping slashes. On a prefixed locale one leading segment equal to the prefix is dropped, so "cn/about", "/cn/about/" and "about" are the same zh route; on an unprefixed locale nothing more is stripped. The same cleaning applies to route and to each options.alternates value, with that value's own locale. An unknown locale is clamped and logged as in forLocale.

alternatesFromLocalizations(localizations, toRoute)

alternatesFromLocalizations<T extends { locale: string; publishedAt?: string | null; [field: string]: any }>(
  localizations: readonly T[] | null | undefined,
  toRoute: (localization: T) => string,
): Record<string, string>

Turns Strapi's localizations { locale publishedAt … } into options.alternates, pairing documents by Strapi's own link rather than by assuming equal slugs. A row without publishedAt is skipped, so query it: leave it out and every row is skipped. "" is kept; null gives {}.

Untyped CMS data (any) can read any field in toRoute, such as l.Slug. A typed array still rejects a field its rows do not have.

Types

interface SeoLocale {
  prefix: string;
  ogLocale: string;
  hreflang: readonly string[];
  enabled?: boolean;
}

interface LocalizedSeoConfig {
  siteUrl: string;
  siteName: string;
  canonicalPolicy?: "passthrough" | "strict-same-origin";
  noIndexPolicy?: "truthy" | "strict";
  defaultLocale: string;
  locales: Record<string, SeoLocale>;
}

interface LocalizedSeoMetadata extends Omit<SeoMetadata, "alternates"> {
  alternates: { canonical: string; languages?: Record<string, string> };
}

Also exported: LocalizedSeo, LocalizedGenerateSEOMetadata, LocalizedMetadataOptions.

For the sitemap, alternateRefs on createSitemapNoIndexFromBuild copies each page's hreflang links from its exported HTML.


Entry ./bilingual — metadata for localized URLs

createBilingualSeo(config)

createBilingualSeo(config: BilingualSeoConfig): BilingualSeo

| config field | type | default | | | --- | --- | --- | --- | | siteUrl | string | — | site origin; trailing slash stripped | | siteName | string | — | last-resort title, behind the per-locale default | | locales | BilingualLocales | — | required; this entry exists for localized URLs | | defaults | Record<string, BilingualLocaleDefaults> | {} | per-locale last-resort title/description | | inherit | readonly string[] | [] | fields resolvePageSeo may copy from the homepage | | loadHomepageSeo | (locale?) => Promise<unknown> \| unknown | — | required for inherit to do anything | | hiddenBuildEnv | readonly HiddenBuildEnvRule[] | [] | env rules that force the whole build noindex |

Returns { forLocale(locale, options?) }.

seo.forLocale(locale, options?)

forLocale(locale: string | undefined, options?: { available?: readonly string[] }):
  { generateSEOMetadata: BilingualGenerateSEOMetadata; resolvePageSeo: BilingualResolvePageSeo }

available narrows the hreflang set to the locales a page was actually translated into. Omit it and every locale is advertised, which is what the fleet does today — and why one site currently points an hreflang="ar" at a page that does not exist.

A locale that is not in locales.all is clamped to locales.default and logged, never thrown: generateMetadata failing takes the whole build with it.

generateSEOMetadata(seoData, path?) — bilingual

type BilingualGenerateSEOMetadata = (
  seoData: BilingualCmsSeo | null | undefined,
  path?: string | null,
) => BilingualSeoMetadata;

Takes two arguments. There is no pageData fallback chain here — the sites using this entry always have an SEO record or a per-locale default, so the main entry's Title / Header.Title / PageName ladder would be code no call site reaches. A third argument is ignored.

A path may be passed with or without its locale prefix — "dining", "/ar/dining/" and "en/dining" all resolve to the same page.

resolvePageSeo(pageSeo)

type BilingualResolvePageSeo = (pageSeo: unknown) => Promise<Record<string, unknown> | undefined>;

Returns a page's own seo untouched, and otherwise copies only the fields listed in inherit from loadHomepageSeo(locale). See inherit.

parseNoIndex(seo)

parseNoIndex(seo: unknown): { noIndex: boolean; follow: boolean }

Also exported from ./bilingual/sitemap, and shared on purpose: the sitemap must exclude exactly the pages that render <meta name="robots" content="noindex">.

| seo.noIndex | verdict | | --- | --- | | true, 1, or the strings "true"/"1"/"yes" (any case, trimmed) | { noIndex: true, follow: false } | | any other non-empty string ("false", "no", "0") | indexed — an explicit negative is an answer, and does not fall through to metaRobots | | absent, null, false | falls through to metaRobots / meta_robots / robots / MetaRobots | | metaRobots containing noindex | { noIndex: true, follow: !/nofollow/ } | | anything else | { noIndex: false, follow: true } |

seo.NoIndex === true is accepted as well as seo.noIndex.

sanitizeSameOriginCanonical(raw, options)

sanitizeSameOriginCanonical(raw: unknown, options: SameOriginCanonicalOptions): string | null

| option | type | default | | | --- | --- | --- | --- | | siteUrl | string | — | site origin, trailing slashes already stripped | | pathSegment | string | — | the page's path below any locale segment; "" on a home page | | homes | readonly string[] | — | URLs a sub-path must not claim; ignored when pathSegment is empty | | onReject | (message: string) => void | console.error | where rejections go |

Returns the accepted canonical, or null so the caller falls back to the computed per-path one. Accepts only an absolute URL on the same scheme and host that is not a home page claimed by a sub-path.

Types

interface BilingualLocales {
  all: readonly string[];              // hreflang order, e.g. ["en", "ar"]
  default: string;                     // x-default, and the clamp target
  ogLocale?: Record<string, string>;   // { en: "en_US", ar: "ar_SA" }
}

interface BilingualLocaleDefaults { title?: string; description?: string }

interface HiddenBuildEnvRule { name: string; equals: string }

interface BilingualSeoMetadata {
  title: { absolute: string };
  description?: string;
  keywords?: string | string[];        // the key is omitted when the CMS value is empty
  alternates: { canonical: string; languages: Record<string, string> };
  openGraph: { /* …as SeoMetadata, plus: */ alternateLocale?: string[] };
  twitter: { card: "summary_large_image"; title: string; description?: string; images?: string[] };
  robots: { index: boolean; follow: boolean; googleBot: { index: boolean; follow: boolean } };
}

BilingualCmsSeo matches CmsSeo except that metaImage is unknown: Strapi v5 is flat ({ url }), v4 wraps it ({ data: { attributes: { url } } }), and both are resolved.

Also exported: BilingualSeoConfig, BilingualSeo, BoundBilingualSeo, ForLocaleOptions, BilingualSeoRobots, NoIndexVerdict, SameOriginCanonicalOptions.

Deliberately not the main entry's SeoMetadata: keywords is omitted here rather than always emitted, and alternates.languages / openGraph.alternateLocale do not exist there.


Entry ./bilingual/sitemap

createLocaleSitemapNoIndex(options)

createLocaleSitemapNoIndex(options: LocaleSitemapNoIndexOptions): LocaleSitemapNoIndex

| option | type | default | | | --- | --- | --- | --- | | apiBase | string | — | Strapi origin | | shape | "v4" \| "v5" | — | response shape of that box. Not cosmetic — see below | | locales | readonly string[] | ["en"] | locale segments to walk | | singleTypes | readonly SitemapSingleType[] | [] | { uid, segments } | | collections | readonly SitemapCollection[] | [] | { uid, field, prefix? } | | extraPathsEnv | string | — | env var of extra comma-separated paths to exclude | | fetch | FetchLike | global fetch | injectable for tests |

Returns { normalizeSitemapPath, getNoIndexPathSetPromise() } — note getNoIndexPathSetPromise takes no argument and caches on the configured base.

localeAlternateRefs(siteUrl, path, locales, options?)

localeAlternateRefs(
  siteUrl: string,
  path: string,
  locales: readonly string[],
  options?: { xDefault?: string; existsIn?: (locale: string, restPath: string) => boolean },
): AlternateRef[]

| param | default | | | --- | --- | --- | | xDefault | locales[0] | locale used for x-default | | existsIn | every locale exists | pass it to stop advertising a translation that was never authored |

Returns { href, hreflang, hrefIsAbsolute: true }[] for next-sitemap's transform hook. Every href gets a trailing slash — with trailingSlash: true an href without one is not the page's canonical and is not in the sitemap's own <loc> set, which is the non-reciprocity condition that makes Google discard the cluster.

const {
  createLocaleSitemapNoIndex,
  localeAlternateRefs,
} = require("@prismetic/seo-utils/bilingual/sitemap");

const { normalizeSitemapPath, getNoIndexPathSetPromise } = createLocaleSitemapNoIndex({
  apiBase: "https://prod-shebara-cms.prismetic.com",
  shape: "v4",                       // v4: rows wrapped in `attributes`. v5: flat rows.
  locales: ["en", "ar"],
  singleTypes: [
    { uid: "homepage", segments: [] },
    { uid: "dining", segments: ["dining"] },
  ],
  collections: [{ uid: "campaign-pages", field: "Path" }],
  extraPathsEnv: "SITEMAP_NOINDEX_PATHS",   // comma-separated, e.g. "/en/internal,/ar/internal"
});

module.exports = {
  siteUrl: SITE_URL,
  transform: async (config, path) => {
    if ((await getNoIndexPathSetPromise()).has(normalizeSitemapPath(path))) return null;
    return {
      loc: path,
      changefreq: config.changefreq,
      priority: config.priority,
      alternateRefs: localeAlternateRefs(config.siteUrl, path, ["en", "ar"]),
    };
  },
};

Types

type CmsShape = "v4" | "v5";
interface SitemapSingleType { uid: string; segments: string[] }
interface SitemapCollection { uid: string; field: string; prefix?: string | null }
interface AlternateRef { href: string; hreflang: string; hrefIsAbsolute: true }

Also exported: LocaleSitemapNoIndexOptions, LocaleSitemapNoIndex, LocaleAlternateOptions, normalizeSitemapPath, FetchLike, parseNoIndex, NoIndexVerdict.


Behaviour

Page metadata

generateSEOMetadata(seoData, path, pageData) returns a Next Metadata object (title, description, keywords, canonical, Open Graph, Twitter, robots). Nothing is ever inherited from the homepage:

| | chain | | --- | --- | | title | seo.metaTitle → Title → Header.Title → PageName → siteName | | description | seo.metaDescription → Excerpt → ShortText → Content → Header.Content → omitted |

A record with a Name (speakers, partners) uses Name — Title (capped at 60 characters) as its title instead, since there Title is the job title, and Name — Title, Company as its description ahead of any Header.Content. Header.Title loses a // separator ("Industry Insights // Hub" → "Industry Insights Hub") but is otherwise untouched — no truncation, no brand suffix. Summaries are trimmed to 150 characters at a word boundary and stripped of <style>/<script> blocks, HTML tags and the six common entities. Every source is trimmed before it is tested, so a field saved as a single space counts as absent.

A page with no summary anywhere gets no description at all — description, openGraph.description and twitter.description are omitted rather than filled with a site-wide string that says nothing about the page. A CMS canonical that points at the site root is ignored on any non-root path, so a page inheriting the homepage's SEO record never declares itself a duplicate of the homepage.

ITE: enabling the strict checks

Two opt-in options on createSeo. Both default to 2.0 behaviour, so a site that does not pass them is unaffected — that is what the 109 original tests and mainEntryUnchanged.test.ts pin.

canonicalPolicy: "strict-same-origin"

A CMS canonicalURL is used only when it is an absolute http(s) URL on the site's own scheme and host, and is not the site root claimed by a sub-path. Anything else falls back to the computed per-path canonical and is logged with console.error.

| CMS value | passthrough (default) | strict-same-origin | | --- | --- | --- | | https://site/other/ | used | used | | https://site/other/ (trailing space) | used, space and all | trimmed, then used | | http://site/other/ on an https site | used as-is | rejected → computed URL, never upgraded | | hhttps://site/x | used as-is | rejected | | a Google Sheets or other off-site link | used as-is | rejected | | [email protected], /about, free text | resolved against metadataBase | rejected | | https://site/ on a sub-path | ignored (2.0 already guards this) | ignored | | blank / absent | computed URL | computed URL, silently |

A rejected canonical is replaced by the page's own URL. It is never rewritten — an http:// value does not become https://, it is discarded.

noIndexPolicy: "strict"

| CMS value | truthy (default) | strict | | --- | --- | --- | | true / 1 / "yes" | noindex, nofollow | noindex, nofollow | | false / null / absent | indexed | indexed | | the string "false", "no", "0" | noindex | indexed | | metaRobots: "noindex, follow" | ignored | noindex, follow kept | | metaRobots: "noindex, nofollow" | ignored | noindex, nofollow |

Expected effect

Enabling either option changes which pages are indexed, so run the CMS audit first and turn them on with the specific list of pages the audit says they repair. The audit output is the record of what changed and why; do not enable these blind.

Sitemap: what the exported page says

createSitemapNoIndexFromBuild runs inside next-sitemap's transform, after next build, so every path it is asked about is already an HTML file. It reads that file's <head> and nothing else:

  • noindex wins over everything. If any <meta name="robots"> carries noindex or none, the page is out. That is what the crawler will read, so it is also what the sitemap says. The CMS is not consulted: a record that says noIndex but a page that renders index, follow is a page bug, and hiding it from the sitemap would not hide it from search.
  • A redirect is not a page. StaticRedirect renders <meta http-equiv="refresh"> and no robots meta; with excludeRedirects: true (default) it is left out. This replaces the per-path fetch of the *-redirects collection that the ITE configs used to make — one uncached HTTP request per sitemap entry.
  • __placeholder__ needs no regex. A route that calls notFound() renders with <meta name="robots" content="noindex"/>, which Next adds itself, so the reader drops it. A placeholder that does not call notFound() — one that renders a "No articles available" page with no robots meta — is a real, indexable page and stays in. Fix the page, not the config.
  • No robots meta means keep. A page that declares nothing is treated as indexable, exactly as a crawler treats it.
  • A missing file means keep, loudly. console.error names the path and the files it looked for. next-sitemap enumerates .next/prerender-manifest.json, not out/, so a path can in principle exist there without a file — that has not been seen on any site.
  • Cost. One synchronous read per path, ~0.35 ms each on a 100 KB page. Mosbuild's 463 pages take 163 ms; the largest ITE export (914 pages) stays under half a second.

Measured inside Mosbuild's real postbuild on 2026-09-21, the reader reproduces the previous sitemap exactly — 435 entries, 7 noindex pages and 19 redirects dropped — with the two regex guards, the per-path redirects fetch and the CMS query removed. /404/ and /_not-found/ never reach transform; next-sitemap skips them itself.

Bilingual sites

A site whose URLs carry a locale segment (/en/dining/, /ar/dining/) uses its own entry point. createSeo is untouched by everything in this section.

// lib/siteConfig.js — the Red Sea fleet
const { createBilingualSeo } = require("@prismetic/seo-utils/bilingual");

const SITE_URL = "https://www.shebara.sa";

const seo = createBilingualSeo({
  siteUrl: SITE_URL,
  siteName: "Shebara",                              // last resort, behind the per-locale default

  locales: {
    all: ["en", "ar"],
    default: "en",                                  // x-default, and the clamp target
    ogLocale: { en: "en_US", ar: "ar_SA" },
  },
  defaults: {
    en: { title: "Shebara", description: "Discover Shebara — …" },
    ar: { title: "شيبارا", description: "اكتشف شيبارا — …" },
  },

  // Only these three may be copied from the homepage by resolvePageSeo.
  inherit: ["metaTitle", "metaDescription", "metaImage"],
  loadHomepageSeo: async (locale) => (await getCachedHomepageData(locale))?.seo,

  hiddenBuildEnv: [
    { name: "NODE_ENV", equals: "development" },
    { name: "VERCEL_ENV", equals: "preview" },
    { name: "NEXT_PUBLIC_ROBOTS_NOINDEX", equals: "1" },
  ],
});

module.exports = { SITE_URL, seo };
export async function generateMetadata({ params }) {
  const { locale } = await params;
  const { generateSEOMetadata, resolvePageSeo } = seo.forLocale(locale);
  return generateSEOMetadata(await resolvePageSeo(page?.seo), "dining");
}

Each page gets its locale segment in the canonical, alternates.languages (one entry per locale plus x-default), openGraph.alternateLocale, the per-locale default title and description, and an omitted keywords key when the CMS value is empty.

This entry is always strict

There is no policy option to set wrong. A CMS canonicalURL is accepted only when it is an absolute URL on the same scheme and host, and is not a home page (the site root or any locale home) claimed by a sub-path. Anything else falls back to the computed per-path canonical. An http:// value on an https site is rejected, never silently upgraded.

This exists because a bare string resolves against metadataBase: [email protected] was pasted into the field and shipped as <link rel="canonical" href="https://site/[email protected]"> on two production sites. Relative values are rejected for the same reason — telling a valid path from a typo is exactly what failed there.

Rejections are logged with console.error, not console.warn. Two of the four sites using this entry build with removeConsole: { exclude: ["error"] }, so a warning would be compiled out of the very builds it exists to report on.

noIndex is read from an explicit allowlist — true, 1, "true" | "1" | "yes" — with any other non-empty string treated as an explicit negative, falling back to metaRobots / robots. "noindex, follow" keeps follow: true rather than collapsing to nofollow.

The main entry defaults to the looser seo.noIndex ?? false test, which reads the string "false" as noindex and ignores metaRobots. Both are known to be wrong, and a single-language site can opt out of them with noIndexPolicy: "strict" — see ITE: enabling the strict checks. It stays the default because switching changes which pages are indexed, so it belongs to a per-site audit.

inherit — what a page may take from the homepage

Default []: nothing. resolvePageSeo(pageSeo) returns a page's own seo untouched and otherwise copies only the listed fields.

Never list canonicalURL, JSON_LD or noIndex. An inherited canonical makes every page without its own SEO declare itself a duplicate of the homepage, and search engines drop it (this was live: five pages on one site, six on another). An inherited JSON_LD makes every sub-page claim to be the resort entity. An inherited noIndex means one tick on the homepage delists the whole site and empties the sitemap.

Note that a page whose seo is an empty {} counts as having its own and inherits nothing. That is current fleet behaviour, kept deliberately; whether an empty CMS component should mean "no SEO record" is an open question for the content team, not a code fix.

hiddenBuildEnv

A list of { name, equals } rules; when any matches, the whole build is noindex, nofollow. Default [], so no env var is read at all. Rules are looked up dynamically, which means Next's build-time inlining of process.env.FOO does not apply — fine for generateMetadata, which runs in Node during the build, but a rule here is invisible to client bundles.

Why the bilingual sitemap filters client-side

shape is not cosmetic: a wrong value makes every seo read undefined, so nothing is ever excluded and the sitemap ships noIndex pages. The mismatch is detected and logged with console.error — check the postbuild log for [sitemap] shape is … before trusting an export. This entry filters client-side rather than with Strapi's filters[seo][noIndex][$eq]=true, because its noIndex rule is broader than that filter and the exclusion set must match exactly the pages that render a noindex meta tag.

No TLS override. The package never sets NODE_TLS_REJECT_UNAUTHORIZED. A CMS whose certificate chain Node cannot verify needs an explicit, per-site opt-in in that site's own config — and only there.

Portability

No ITE domain or site name is hardcoded in the shipped code — they appear only in comments and test fixtures. What the package actually couples to is field names and URL conventions, and that differs sharply per entry point.

Works on any project

Pure functions with no CMS assumptions at all. Import and use them anywhere:

| export | entry | | --- | --- | | summarise | . | | cleanHeaderTitle | . | | JsonLd, getStructuredDataScriptInnerHtmls | . — generic apart from expecting the field to be named JSON_LD | | sanitizeSameOriginCanonical | ./bilingual | | normalizeSitemapPath | ./sitemap, ./bilingual/sitemap | | localeAlternateRefs | ./bilingual/sitemap | | contentSignalRobotsTxt, DEFAULT_CONTENT_SIGNAL | ./sitemap |

Works on any Strapi project

createSeo, createLocalizedSeo and createBilingualSeo read metaTitle, metaDescription, keywords, noIndex, canonicalURL and metaImage — the Strapi SEO plugin's field names, not an ITE invention. Any Strapi site using that plugin fits without modification.

createLocaleSitemapNoIndex takes a plain apiBase and your own uids and works against Strapi v4 or v5 via shape — but only for a site whose URLs carry a locale segment. See the sitemap note below.

parseNoIndex accepts noIndex, NoIndex, metaRobots, meta_robots, robots and MetaRobots, so it tolerates most conventions.

ITE-specific

  • generateSEOMetadata's pageData fallback chain is hardcoded: Title, Header.Title, Header.Content, PageName, Name, Company, Excerpt, ShortText, Content. There is no accessor option, unlike @prismetic/article-filters' ArticleAccessors. On another CMS these simply never match — see below.

  • createSitemapNoIndexFromBuild serves any output: "export" site — it reads HTML, not a CMS. The CMS-backed createLocaleSitemapNoIndex does not: it unconditionally prefixes /<locale> to every path it emits, so on a site whose URLs have no locale segment the exclusion set never matches:

    createLocaleSitemapNoIndex({ apiBase, shape: "v5", locales: ["en"], … });
    // set contains "/en/about"; the sitemap emits "/about" → no match, nothing excluded
    // locales: [""] does not help — it yields the malformed "//about"

    This fails silently: no error, an empty-looking exclusion set, and noIndex pages ship in the sitemap. Write the transform yourself instead — it is ~20 lines against your own CMS, and normalizeSitemapPath is exported so both sides of the comparison agree.

  • generateLlmsTxt takes configurable GraphQL roots but a fixed query shape: seo { metaTitle metaDescription } and Data { Title LinkTo Links { Text LinkTo } }, plus NavText { Text1 Text2 } (navText: "component") or a bare NavText string (navText: "string") when asked for. It reads the static export, so it serves an output: "export" site and runs after next build.

Using it on another project today

The hardcoded pageData chain degrades gracefully rather than breaking — unmatched field names are simply absent, and the seo component carries the page:

const { generateSEOMetadata } = createSeo({
  siteUrl: "https://acme.io",
  siteName: "Acme",
});

generateSEOMetadata(
  { metaTitle: "Pricing", metaDescription: "What it costs." },
  "pricing",
  { post_title: "Pricing", post_excerpt: "never read" },   // non-ITE field names
);
// title      → "Pricing"
// description→ "What it costs."
// canonical  → "https://acme.io/pricing/"

generateSEOMetadata(null, "pricing", { post_title: "Pricing" });
// title → "Acme"   — falls back to siteName, no crash, nothing undefined

So the recipe for another project is to shape the first argument yourself and skip the third:

const toSeo = (page) => ({
  metaTitle: page.post_title,
  metaDescription: page.post_excerpt,
  keywords: page.tags?.join(", "),
  canonicalURL: page.canonical_url,
  noIndex: page.noindex,
  metaImage: page.og_image ? { url: page.og_image } : null,
});

export async function generateMetadata() {
  return generateSEOMetadata(toSeo(page), page.slug);
}

That is a few lines per project and needs no change to this package. Making the mapping a built-in mapSeo option, and replacing the hardcoded pageData chain with accessors, is the natural next step if more than one non-ITE project needs it.

Limits

  • generateLlmsTxt fails the build on a CMS, HTTP or GraphQL error, missing homepage SEO, a missing navbar or a missing export, a transient CMS 503 included: it rejects and next-sitemap's postbuild fails with it. Re-run the build once the CMS answers.
  • llms.txt lists only same-origin navbar pages. External links, pages the navbar does not link and the homepage, unless the navbar links it, never appear.
  • A wrong shape on ./bilingual/sitemap fails silently apart from its console.error. Nothing is excluded and noIndex pages ship.
  • ./bilingual/sitemap always prefixes /<locale>, so it cannot serve a site whose URLs have no locale segment — and that one fails with no message at all. See Portability.
  • forLocale without available advertises every locale, including translations that were never authored.
  • The main entry's default policies are known to be wrong ("false" read as noindex; metaRobots ignored) and stay default because changing them changes which pages are indexed. They are a per-site audit decision.
  • CanonicalPolicy, NoIndexPolicy, SeoRobots and parseNoIndex are not exported from . even though they exist in the source. Pass policies as string literals.
  • Requires Node 20.19+ / 22.12+ when loaded from a CommonJS config.
  • An empty {} SEO component counts as a page having its own, so it inherits nothing.

Upgrading to 3.0

Breaking, on ./sitemap only. ., ./localized, ./bilingual and ./bilingual/sitemap are unchanged. Three things break:

  1. llms.txt has a new format. It lists only the navbar's own same-origin links that the export can serve, as one flat Markdown list of the URLs the pages are served at. The Homepage line, the dropdown headings, external links, noindex pages and missing pages are gone, and a redirect lists its target. 2.x wrote:

    # Mosbuild 2026
    > The big show.
    
    ## Key Sections
    
    - Homepage: https://mosbuildexpo.com/
    - Visit: https://mosbuildexpo.com/visit
    - Exhibit
      - Why exhibit: https://mosbuildexpo.com/exhibit/why
      - Book a stand: https://forms.example.com/stand

    3.0 writes:

    # Mosbuild 2026
    
    > The big show.
    
    ## Pages
    
    - [Visit](https://mosbuildexpo.com/visit/)
    - [Why exhibit](https://mosbuildexpo.com/exhibit/why/)
  2. generateLlmsTxt rejects where 2.x logged the failure and wrote nothing. A CMS, HTTP or GraphQL error, missing homepage SEO, a missing navbar or a missing export now fails the build; generateLlmsTxt has the full list.

  3. The deprecated CMS helper is removed, as 2.3 announced: createSitemapNoIndex, DEFAULT_CONTENT_TYPES, parseRedirectsApiUrl, SitemapContentType, SitemapNoIndexOptions and SitemapNoIndex are gone from ./sitemap. Use createSitemapNoIndexFromBuild; Upgrading to 2.3 shows the switch.

Existing generateLlmsTxt call sites need no config change, but the export must already be in outDir when next-sitemap runs. It is, on every ITE site's postbuild.

New and optional: navText ("component" or "string") adds the navbar's event date and location under the description. A shape the site's schema does not have fails the build.

Upgrading to 2.4.1

Types and package metadata only. No output changes; every dist/*.js file is byte-identical to 2.4.0. alternatesFromLocalizations accepts untyped (any) data and lets toRoute read any field on it, where 2.4.0 rejected l.Slug. package.json declares "sideEffects": false; no module in dist/ runs code at import.

Upgrading to 2.4

Additive. No output changes. A new entry, ./localized. On ./sitemap, createSitemapNoIndexFromBuild gains alternateRefs and an opt-in cache; a config that uses neither reads and logs exactly as in 2.3. ., ./bilingual and ./bilingual/sitemap are unchanged.

Upgrading to 2.3

Additive. No output changes. One new export on ./sitemap, createSitemapNoIndexFromBuild, plus its two pure pieces inspectExportedHtml and exportedFilesFor. createSitemapNoIndex is deprecated but unchanged; it goes in 3.0.

A site that upgrades and does nothing else moves nothing. A site that switches its next-sitemap.config.js to the build reader can delete three things from transform:

-const { normalizeSitemapPath, getNoIndexPathSetPromise } =
-  createSitemapNoIndex({ contentTypes: [...DEFAULT_CONTENT_TYPES, { uid: "campaign-pages", field: "PagePath", prefix: "lp" }] });
+const buildNoIndex = createSitemapNoIndexFromBuild();

   transform: async (config, path) => {
-    if (/\/__placeholder__\/?$/.test(path)) return null;
-    if (/\/lp\/campaign-components\/?$/.test(path)) return null;
-    if (await customIgnoreFunction(path)) return null;          // the redirects fetch
-    const noIndexSet = await getNoIndexPathSetPromise(REDIRECTS_FETCH_URL);
-    if (noIndexSet.has(normalizeSitemapPath(path))) return null;
+    if (buildNoIndex.shouldExclude(path, config)) return null;
     return { loc: path, /* … */ };
   },

Before switching, check two things in the site's own out/:

  1. Every placeholder route calls notFound(). A placeholder that renders a page instead (/articles/placeholder/, /articles/no-articles-available/) has no robots meta and will be listed — as it already is today, since the __placeholder__ regex never matched it either. Fix the route.
  2. Any page the old config excluded by CMS noIndex renders noindex itself. It does when the route uses generateSEOMetadata; a route that builds its own metadata may not.

The sitemap should come out identical. Diff out/sitemap.xml before and after.

Upgrading to 2.2

One output change, main entry only. A description generated from page content (Excerpt, ShortText, Content, Header.Content, or a person's name line) is cut at 150 characters instead of 160, so that a cut description, ellipsis included, stays under the 155-character and 985-pixel marks Screaming Frog flags. A crawl of four sites on 2.1 found every generated description that reached the old cap flagged as too long.

What moves on a site that upgrades:

  • a generated description that was cut at 160 is cut at 150 instead;
  • a source between 151 and 160 characters that used to pass through whole is now cut and given an ellipsis — the one case that gets shorter and loses its ending;
  • a hand-written seo.metaDescription is never summarised and does not change;
  • titles, canonicals, robots, JSON-LD, the sitemap helpers and the ./bilingual entries, which never call summarise, do not change.

Consumers pin exact versions, so nothing moves until a site bumps. summarise(text, 160) still gives the old cut where a site calls it directly.

Upgrading to 2.1

Nothing to do. 2.1 adds two new entry points and changes nothing that an existing site imports: src/seo.ts, src/sitemap.ts and src/index.ts are byte-identical to 2.0, and the 109 tests written against 2.0 pass unedited.

Bilingual sites move their lib/seo.js and lib/sitemapNoIndex.js onto @prismetic/seo-utils/bilingual and /bilingual/sitemap. src/__fixtures__/redsea/ holds 1,080 cases captured from four live bilingual sites that those entries must reproduce.

Upgrading to 2.0

createSeo no longer takes siteTitle or siteDescription; it takes siteName — the event's name, used as the Open Graph site name and as the last-resort page title. Each site's lib/siteConfig.js swaps its two constants for one SITE_NAME, and app/layout.tsx uses it as the root title with no root description. Delete the if (!seo) seo = await fetchHomepageSEO() fallback in front of every <JsonLd>: a page with no SEO record of its own now renders no JSON-LD rather than the homepage's.

Development

npm test -w seo-utils      # vitest — 516 tests across 15 files, type tests included
npm run build -w seo-utils

src/__fixtures__ holds byte-identical copies of the two legacy lib/seo.js variants; the tests prove the package reproduces them. redsea.golden.test.ts replays 1,080 cases captured from four live bilingual sites, and mainEntryUnchanged.test.ts pins the main entry against 2.0. localized.test-d.ts is checked by tsc through tsconfig.typecheck.json; an unused @ts-expect-error fails the run. The invalid and valid locale configs in test-fixtures/localeConfigCases.ts, at the repo root, run here and in i18n-utils, so the two validate() copies cannot drift.

License

MIT