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

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

Readme

hreflang-forge

test npm version npm downloads license

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.com

or, running the script directly (e.g. from a clone):

node bin/hreflang-forge.js --base-url https://example.com

Writes 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/ or src/app/, one route per directory containing page.{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/ or src/pages/. _app, _document, _error, _middleware, and api/ are skipped. index.js maps to the parent path.

Locales, checked in order:

  1. Legacy Pages Router i18n: { locales: [...], defaultLocale: ... } in next.config.{js,mjs,ts,cjs} (handles nested objects/arrays inside the block, e.g. domains: [...]).
  2. A locales array in a next-intl-style config file: i18n.ts/i18n/config.ts, the routing.ts/defineRouting() convention, or straight in middleware.ts (createMiddleware({ locales: [...] })) if there's no separate config file.
  3. Locale-code subfolders/files under messages/ or locales/.

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.txt

Commit 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 test

Contributing

See CONTRIBUTING.md. Security issues: see SECURITY.md. Release history: CHANGELOG.md.

License

MIT