hreflang-forge
v1.4.0
Published
Zero-dependency CLI that scans a Next.js project and generates an i18n-aware sitemap.xml with hreflang alternates
Maintainers
Readme
hreflang-forge
Zero-dependency CLI. Scans a Next.js project (Pages Router or App Router),
detects routes + i18n locales, generates sitemap.xml with hreflang
alternate links for every page.
Single file. No npm install needed beyond Node itself.
Usage
npx hreflang-forge --base-url https://example.comor, running the script directly (e.g. from a clone):
node bin/hreflang-forge.js --base-url https://example.comWrites to public/sitemap.xml by default.
Options
| Flag | Description |
|---|---|
| --base-url <url> | Site base URL (required) |
| --dir <path> | Project root (default: cwd) |
| --out <path> | Output file, relative to --dir (default: public/sitemap.xml) |
| --dry-run | Print XML to stdout instead of writing a file |
| --default-locale-prefix | Assume the default locale is also prefixed in URLs (default: unprefixed, matching Next.js legacy i18n behavior) |
| --lastmod | Include <lastmod> per URL, from the page file's mtime |
| --changefreq <value> | Include <changefreq> on every URL (always\|hourly\|daily\|weekly\|monthly\|yearly\|never) |
| --no-priority | Omit <priority> (default: on, computed from route depth — home is 1.0, each path segment subtracts 0.1, floor 0.5) |
| --params <file.json> | Expand dynamic routes ([slug], [...slug], etc.) using values from a JSON file |
| --exclude <glob[,glob]> | Exclude URLs matching a glob (* = one path segment, ** = any depth). Repeatable, or comma-separated in one flag |
| --chunk-size <n> | Max URLs per sitemap file before splitting into a sitemap index (default: 50000, the sitemaps.org limit) |
| --robots-txt [path] | Append/update a Sitemap: line in robots.txt (default: robots.txt under --dir) — idempotent, replaces any existing line instead of duplicating |
| -h, --help | Show help |
| -v, --version | Show the installed version |
What it detects
Routers — both are scanned if present:
- App Router:
app/orsrc/app/, one route per directory containingpage.{js,jsx,ts,tsx}. Route groups(group)don't add a URL segment. Parallel route slots (@slot) and private folders (_lib) are skipped.api/is skipped. - Pages Router:
pages/orsrc/pages/._app,_document,_error,_middleware, andapi/are skipped.index.jsmaps to the parent path.
Locales, checked in order:
- Legacy Pages Router
i18n: { locales: [...], defaultLocale: ... }innext.config.{js,mjs,ts,cjs}(handles nested objects/arrays inside the block, e.g.domains: [...]). - A
localesarray in anext-intl-style config file:i18n.ts/i18n/config.ts, therouting.ts/defineRouting()convention, or straight inmiddleware.ts(createMiddleware({ locales: [...] })) if there's no separate config file. - Locale-code subfolders/files under
messages/orlocales/.
If no locales are detected, a plain sitemap is generated with no hreflang alternates.
App Router + [locale] segment — if a route's first segment is literally
[locale], the tool treats it as the locale slot: it substitutes each
detected locale into that segment rather than treating it as an unresolvable
dynamic param. This matches the standard next-intl / App Router i18n setup.
Prefix-based locale routing — for Pages Router legacy i18n (or App Router
projects without a [locale] segment), locale-prefixed URLs are assumed:
defaultLocale is unprefixed (/about), other locales are prefixed
(/fr/about) — matching Next.js's default i18n behavior. Pass
--default-locale-prefix if your setup prefixes every locale, including the
default.
Domain-based locale routing — if next.config.js's legacy i18n block
has a domains: [{ domain, defaultLocale, locales? }] array, each locale
gets a URL on its own domain instead of a path prefix on one domain (e.g.
https://example.de/about instead of https://example.com/de/about),
matching Next.js's domain-based routing.
Each locale is assigned to exactly one domain (no duplicate URLs across
domains): a domain whose locales array explicitly claims it wins, then
whichever domain has it as defaultLocale, then the first domain that
doesn't restrict locales (which implicitly serves every locale via
prefix). hreflang alternates cross-reference every domain correctly.
basePath and trailingSlash
If next.config.js sets basePath and/or trailingSlash: true, every URL
in the sitemap (including hreflang alternates, sitemap-index entries, and
the robots.txt Sitemap: line) is adjusted to match — basePath applies
to public/ static files too in Next.js, so sitemap.xml itself is served
under it. No flag needed; this is detected automatically alongside i18n.
Dynamic routes
Routes with dynamic segments ([slug], [id], [...slug], etc.) need a
data source to resolve. The [locale] segment itself doesn't count as
dynamic — it's resolved from your locale list automatically.
For anything else, pass --params <file.json> mapping each route's
content path template (locale-agnostic — drop any [locale] segment) to
an array of values:
{
"/blog/[slug]": ["hello-world", "second-post"],
"/products/[category]/[id]": [
{ "category": "shoes", "id": "123" },
{ "category": "hats", "id": "456" }
],
"/docs/[...slug]": [
"getting-started",
["guides", "advanced", "auth"]
]
}Rules:
- A template with exactly one dynamic segment accepts a flat array of strings.
- A template with multiple dynamic segments needs an array of objects,
keyed by segment name (without brackets or the
...prefix). - Catch-all segments (
[...slug]) accept either a single string ("guides/advanced") or an array of path parts (["guides", "advanced"]). - Templates with no matching entry are skipped, with a count reported to stderr.
- Values are combined with locales automatically if i18n is detected — you don't need to repeat entries per locale.
Generate the params file from your CMS/data layer as a build step, or write it by hand for a small, mostly-static site.
Example output
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/en/about</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about"/>
<xhtml:link rel="alternate" hreflang="ko" href="https://example.com/ko/about"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/about"/>
</url>
</urlset>Use as a GitHub Action
- uses: actions/checkout@v4
- uses: wonsukchoi/hreflang-forge@v1
with:
base-url: https://example.com
# optional:
# dir: .
# out: public/sitemap.xml
# params: sitemap-params.json
# exclude: /admin/**,/draft/**
# changefreq: weekly
# chunk-size: 50000
# lastmod: 'true'
# no-priority: 'true'
# default-locale-prefix: 'true'
# robots-txt: robots.txtCommit the generated public/sitemap.xml as part of your build, or add a
step after it to deploy/upload the file — the action just writes it to disk,
it doesn't commit or publish anything on its own. See action.yml for every
input.
Migrating from next-sitemap
next-sitemap is the
most common sitemap generator for Next.js, but it's config-file-driven,
requires a dependency + a postbuild script, and doesn't generate hreflang
alternates out of the box — you have to hand-write an alternateRefs array
per page. hreflang-forge is a flags-only CLI: no config file, no
dependency, and hreflang alternates come from your existing i18n setup
automatically.
Rough field mapping from next-sitemap.config.js:
| next-sitemap | hreflang-forge |
|---|---|
| siteUrl | --base-url |
| outDir / sitemapBaseFileName | --out |
| generateRobotsTxt: true | --robots-txt |
| exclude: ['/admin/**'] | --exclude '/admin/**' |
| changefreq: 'weekly' | --changefreq weekly |
| priority: 0.7 | computed automatically from route depth (or --no-priority to omit) |
| sitemapSize: 5000 | --chunk-size 5000 |
| per-page alternateRefs: [...] in getServerSideProps/transform | detected automatically from your i18n config — nothing to write per page |
| dynamic route additionalPaths() | --params <file.json> (see Dynamic routes) |
If you're using next-sitemap's robotsTxtOptions.additionalSitemaps for
multiple sitemaps, or its per-route transform() for anything not covered
above, keep it side by side — nothing stops the two tools running in the
same project. hreflang-forge just aims to replace the common case (build a
sitemap + robots.txt from your route tree and i18n config) without a
dependency.
Large sites
When the URL count exceeds --chunk-size (default 50000, the sitemaps.org
per-file limit), --out becomes a sitemap index file, and the actual
URLs are split across <out-basename>-1.xml, -2.xml, etc. written
alongside it. Sitemap index entries assume the output directory is served at
your site root (the default public/ convention), so each chunk's URL is
<base-url>/<chunk-filename>.
sitemaps.org also caps each file at 50MB uncompressed, independent of the
50,000-URL count — a file can stay under --chunk-size and still blow past
the byte limit if URLs or hreflang alternate lists are unusually long. A
warning is printed to stderr (not a hard error) if any generated file
exceeds it; lower --chunk-size if you see one.
Tests
Zero-dependency test suite (spawns the CLI against fixture projects in
test/fixtures/, asserts on stdout/stderr):
npm testContributing
See CONTRIBUTING.md. Security issues: see SECURITY.md. Release history: CHANGELOG.md.
License
MIT
