Produce what goflag audits
@goflag/next declares a Next.js App Router site's routes once, then derives the metadata, hreflang cluster, sitemap and robots.txt from it.
goflag tells you what is wrong. It does not tell you how to stop writing it
wrong. @goflag/next is the other half: a route registry for the Next.js App
Router that produces the HTML goflag would have nothing to say about.
The point is not convenience. A sitemap derived separately from the metadata is
two derivations of one truth, held in agreement by vigilance, and goflag has two
checks for the day vigilance lapses:
hreflang.cluster-incomplete when the
sitemap publishes a locale the <head> never names, and
hreflang.sitemap-mismatch — a question
rather than a finding — for the other direction. Projecting both artefacts from
one registry makes either one unrepresentable.
Install
pnpm add -D @goflag/nextNode >=22. next is a peer dependency and the runtime depends on nothing
else. It is a build-time library: nothing it produces reaches the browser.
Declare the site once
import { collection, defineSite } from "@goflag/next";
import { allDocs, allLegals } from "content-collections";
export const site = defineSite({
baseUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com",
name: "Example",
locales: ["en", "fr", "pt"],
defaultLocale: "en",
indexable: process.env.APP_ENV === "production",
});
export const routes = site.routes({
home: { path: "" },
legal: collection(allLegals, { path: (d) => `/${d.slug}`, locale: (d) => d.locale }),
docs: collection(allDocs, { path: (d) => `/docs/${d.slug}`, locale: "en" }),
});The library reads no environment variable. The two lookups above are your site's conventions, not something a library gets to name, and keeping them out means its tests have nothing to stub. See the route registry for what the shapes mean.
Then three files stop containing logic
export async function generateMetadata({ params }) {
const { locale } = await params;
return routes.metadata({ path: "", locale, title: "…", description: "…" });
}export default () => routes.sitemap({ lastModified: new Date() });export default () => routes.robots();Each returns the native Next type (Metadata, MetadataRoute.Sitemap,
MetadataRoute.Robots), so there is nothing to unlearn and you can adopt one
output without adopting the others. A library you can only adopt whole does not
get adopted.
What goflag reads back
The two halves meet in one artefact. Every localized route emits its whole
hreflang cluster — in the <head> and as xhtml:link in the sitemap, with an
x-default that always points at a page the site serves. That is precisely the
declaration goflag reads to decide which URLs are one
page in several languages.
The consequence is worth stating plainly: on a site built from this registry,
the auditor's translation matrix rests on what the site declares rather than on
what its paths happen to look like. A route whose locales use different slugs
still needs
a key to say so — that is
the one thing the registry cannot infer, and the one thing that turns a fully
translated pair into a phantom hole if it goes unsaid.
What it is worth
Measured on the two sites that migrated, not estimated:
| Site | Before | After | Findings before and after |
|---|---|---|---|
| This one | 252 | 140 | unchanged |
| A second, unrelated site | 224 | 123 | 39, identical |
Lines of production code over the same scope. The second migration was the real test, because its API was not derived from that site, and it is where four defects surfaced that the first one could not have shown.
Where to go next
- The route registry:
defineSite,routes, and what a locale policy per route buys you. - What it refuses to build: the checks that fail your build instead of shipping quietly.