@prismetic/seo-utils
v3.0.0
Published
Next.js metadata, JSON-LD and sitemap noIndex helpers for Strapi-backed sites — React 19 / Next 16
Maintainers
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 caretFive 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 | nullCollapses 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 ( & < > " '), 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 world</p>"); // "Hello world"
summarise(" "); // null
summarise(42); // nullThe <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 | nullRemoves 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): stringThe 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:
siteUrlis not an absolute http(s) origin;navTextis set to anything but"component"or"string";<outDir>/index.htmldoes not exist, checked before any request is sent;- the request fails, or the response has
ok: false(the message includes itsstatuswhen present and any GraphQL error messages in its body); - the body is not JSON;
- the body has a non-empty GraphQL
errorsarray, even next todata; <homepageType>.seo.metaTitleis blank, or the homepage record is missing;<navBarType>.Datais not a list;- a file in the export cannot be read, or
llms.txtcannot 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 & " ' < > 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 iscreateSeo's, key for key; - in config order: the current locale and its enabled counterparts, each with its
hreflangcodes in order, thenx-default→ the default locale's URL when the default locale is among them; - every URL ends in
/; an unprefixed home is the baresiteUrl + "/".
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:
noindexwins over everything. If any<meta name="robots">carriesnoindexornone, 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 saysnoIndexbut a page that rendersindex, followis a page bug, and hiding it from the sitemap would not hide it from search.- A redirect is not a page.
StaticRedirectrenders<meta http-equiv="refresh">and no robots meta; withexcludeRedirects: true(default) it is left out. This replaces the per-path fetch of the*-redirectscollection that the ITE configs used to make — one uncached HTTP request per sitemap entry. __placeholder__needs no regex. A route that callsnotFound()renders with<meta name="robots" content="noindex"/>, which Next adds itself, so the reader drops it. A placeholder that does not callnotFound()— 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.errornames the path and the files it looked for.next-sitemapenumerates.next/prerender-manifest.json, notout/, 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'spageDatafallback 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.createSitemapNoIndexFromBuildserves anyoutput: "export"site — it reads HTML, not a CMS. The CMS-backedcreateLocaleSitemapNoIndexdoes 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
transformyourself instead — it is ~20 lines against your own CMS, andnormalizeSitemapPathis exported so both sides of the comparison agree.generateLlmsTxttakes configurable GraphQL roots but a fixed query shape:seo { metaTitle metaDescription }andData { Title LinkTo Links { Text LinkTo } }, plusNavText { Text1 Text2 }(navText: "component") or a bareNavTextstring (navText: "string") when asked for. It reads the static export, so it serves anoutput: "export"site and runs afternext 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 undefinedSo 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
generateLlmsTxtfails 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.txtlists 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
shapeon./bilingual/sitemapfails silently apart from itsconsole.error. Nothing is excluded and noIndex pages ship. ./bilingual/sitemapalways 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.forLocalewithoutavailableadvertises every locale, including translations that were never authored.- The main entry's default policies are known to be wrong (
"false"read as noindex;metaRobotsignored) and stay default because changing them changes which pages are indexed. They are a per-site audit decision. CanonicalPolicy,NoIndexPolicy,SeoRobotsandparseNoIndexare 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:
llms.txthas 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/stand3.0 writes:
# Mosbuild 2026 > The big show. ## Pages - [Visit](https://mosbuildexpo.com/visit/) - [Why exhibit](https://mosbuildexpo.com/exhibit/why/)generateLlmsTxtrejects 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;generateLlmsTxthas the full list.The deprecated CMS helper is removed, as 2.3 announced:
createSitemapNoIndex,DEFAULT_CONTENT_TYPES,parseRedirectsApiUrl,SitemapContentType,SitemapNoIndexOptionsandSitemapNoIndexare gone from./sitemap. UsecreateSitemapNoIndexFromBuild; 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/:
- 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. - Any page the old config excluded by CMS
noIndexrendersnoindexitself. It does when the route usesgenerateSEOMetadata; 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.metaDescriptionis never summarised and does not change; - titles, canonicals, robots, JSON-LD, the sitemap helpers and the
./bilingualentries, which never callsummarise, 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-utilssrc/__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
