The Lokali GitHub Action detects your messages/{lang}.json and locales/{lang}/*.json trees, including the App Router app/[locale]/… convention and ICU MessageFormat placeholders — then posts a per-language coverage table on every PR.
What the Action checks for next-intl
Lokali recognises the layouts the next-intl App Router helper ships with, and any paths you point it at via i18n/request.ts or getRequestConfig.
messages/{lang}.json file plus recursive messages/{lang}/{namespace}.json subdirectory layout used as the source of truth.locales/{lang}/*.json — used by older next-intl projects and teams that imported from Crowdin / Phrase exports.app/[locale]/… route segment and matches the matching routing.ts entries so missing locales show up before they ship.{name}, {count, plural, one {# thing} other {# things}}, and {date, date, short} placeholders and diffs them per locale for drift.messages/{lang}/auth/login.json, messages/{lang}/billing/invoice.json — each namespace is reported independently in the PR comment.next-intl.config.js / i18n/request.ts for any localePrefix + localeDetection settings that affect which locales are in scope.Common coverage gaps the Action flags
These three failure modes hit App Router teams most often — each one lands in the comment as both a missing-key row and the headline error message that opens the Coverage delta PR.
routing.ts without a matching messages/{locale}.jsonrouting.ts change to expose ko for production. The routing config recognises it, but messages/ko.json doesn't exist yet — the Action flags a structural gap with 0% on the new locale and opens a translation PR seeded from messages/en.json.en baselinemessages/fr/billing.json with a full billing namespace but no messages/en/billing.json sibling. The comment flags the missing baseline rather than counting the French file as 100% — closing the gap means merging en first.useTranslations key referenced in components but absent from messages/en.jsonuseTranslations('Pricing')('cta.trial') but messages/en.json only defines Pricing.cta.trialDays. The Action cross-references the component's useTranslations calls (when present in the diff) and flags the missing parent key with a backtrace to the failing component.Sample Action report on a next-intl PR
What lokali[bot] posts in the PR thread after a push touches messages/en.json. The same table is dropped into the first-run Coverage delta PR.
## i18n Coverage Report · next-intl
Baseline: `messages/en.json` · 312 keys · locales: 8 · App Router layout
| Locale | Coverage | Total | Translated | Missing |
|----------|----------|-------|------------|---------|
| 🟢 en | 100% | 312 | 312 | 0 |
| 🟢 es | 96% | 312 | 300 | 12 |
| 🟡 fr | 91% | 312 | 285 | 27 |
| 🟡 de | 88% | 312 | 275 | 37 |
| 🟡 ja | 79% | 312 | 247 | 65 |
| 🟡 pt | 74% | 312 | 232 | 80 |
| 🔴 zh | 58% | 312 | 182 | 130 |
| 🔴 ko | 22% | 312 | 69 | 243 |
Namespace breakdown
• `Index` (root) 142 keys · lowest: ko (47%)
• `Billing` 68 keys · lowest: ko (12%) ← flagged
• `Auth` 54 keys · lowest: zh (39%)
• `Settings` 48 keys · lowest: ko (8%) ← flagged
ICU placeholder drift detected in:
• `Billing.invoiceDue` — `de` missing `{dueDate, date, short}` token
• `Auth.verifyEmail` — `ja` missing `{minutes, plural, …}`
Coverage delta PR opened: #1234 — `lokali: coverage-delta-2026-08-11`
One URL works for both. Coverage comments arrive on your next PR, and the Coverage delta PR closes the gap automatically — across every namespace, including App Router route segments.