packages/cli/skills/pikku-i18n/SKILL.md
Wire i18n into a Pikku frontend (Vite SPA, Vite SSR, or Next.js app-router) with react-i18next + i18next. English by default, every user-facing string goes through a `t()` token, and additional languages are served under `/de` `/es` URL prefixes. TRIGGER when: scaffolding or editing a frontend and writing user-facing text, adding a second language, or asked to "make this translatable / use tokens / add i18n". DO NOT TRIGGER for backend functions, error messages thrown from functions, or log output.
npx skillsauth add pikkujs/pikku pikku-i18nInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill as an execution checklist, not reference material.
t('some.token') and put the English copy in i18n/en.json. This holds even when the app ships only English; the tokens are the seam a second language slots into later.i18n/<lang>.json file per language per app, sitting next to the i18n config in a single i18n/ folder. English (en) is the default and is the only locale registered until someone adds another.tsc then its build. For Next.js, a clean dev is not enough — run build, because the RSC page-data collection step is where i18n wiring mistakes surface.react-i18next + i18next. Nothing else.i18n/ folder — src/i18n/<lang>.json (Vite) or app/i18n/<lang>.json (Next.js) — imported statically. Do not put them under public/ — Vite cannot import from public/, and a runtime fetch is unnecessary: UI-string files are a few KB, so static-import every locale and move on. Do not reach for i18next-http-backend, lazy import(), or per-locale code-splitting.en. Adding a language = drop i18n/<lang>.json, import + register it in the config, done. Its content is then served under the /<lang> URL prefix; the default locale needs no prefix.auth.login.title, board.createCta). Interpolate with {{name}} and pass t('key', { name }).Tokens are typed, not stringly-typed. Each config augments i18next's
CustomTypeOptions with resources: { translation: typeof en } (the block is in
every config shape below). i18next flattens typeof en into a union of dot-path
keys, so:
t('auth.login.titel') (typo) or t('auth.removed') (deleted key) is a TypeScript error, not a silent runtime auth.removed string.de satisfies typeof en fails to compile if de.json is missing keys or has drifted from en.json.This is enforced at deploy: the build pipeline runs each frontend's tsc
(yarn tsc / tsc --noEmit) before building it, and a type error aborts the
deploy. vite build does not type-check on its own, so this gate is the only
thing standing between a broken/missing token and production. Keep a "tsc": "tsc --noEmit" script in every frontend's package.json so the gate uses it.
The type gate catches invalid tokens but not inlined strings — a hardcoded
<h1>Welcome</h1> compiles fine. Catching those is best-effort (the builder
agent is told never to inline) plus the debug mode below.
Each config registers an i18next postProcessor named i18nDebug that, when
enabled, masks every translated string to block glyphs (█). The trick: with
it on, anything still readable on screen is text that never went through a
token — i.e. a hardcoded/inlined string. It's a visual leak detector for
missing i18n, not a runtime feature, so it ships off by default.
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1')
return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
const i18nDebugPostProcessor = {
type: 'postProcessor' as const,
name: 'i18nDebug',
process: (value: string) =>
isI18nDebug() ? value.replace(/\S/g, '█') : value,
}
// register on the instance: `.use(i18nDebugPostProcessor)` and add
// `postProcess: ['i18nDebug']` to `.init({ ... })`.
?i18n-debug in the URL or localStorage['i18n-debug'] = '1'.I18N_DEBUG=1 (the helper only checks the env var server-side).All the bundled templates already wire this; mirror it when hand-wiring i18n in a new app. (A future e2e check can flip the flag and assert no unmasked text renders.)
src/i18n/config.ts:
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './en.json'
// Typed tokens. i18next flattens this resource type into dot-path keys, so
// `t('auth.login.title')` is checked and a typo/removed key is a compile error.
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation'
resources: {
translation: typeof en
}
}
}
export const supportedLocales = ['en'] as const
export type Locale = (typeof supportedLocales)[number]
export const defaultLocale: Locale = 'en'
export function detectLocale(pathname: string): Locale {
const segment = pathname.split('/')[1]
if (supportedLocales.includes(segment as Locale)) return segment as Locale
if (typeof navigator !== 'undefined') {
const lang = navigator.language?.split('-')[0]
if (supportedLocales.includes(lang as Locale)) return lang as Locale
}
return defaultLocale
}
i18n.use(initReactI18next).init({
resources: { en: { translation: en } },
lng:
typeof window !== 'undefined'
? detectLocale(window.location.pathname)
: defaultLocale,
fallbackLng: defaultLocale,
interpolation: { escapeValue: false },
})
export default i18n
Import it once for its side effect at the app entry (import './i18n/config' in main.tsx), then use the hook in components:
import { useTranslation } from 'react-i18next'
function Page() {
const { t } = useTranslation()
return <h1>{t('landing.title')}</h1>
}
Non-component helpers (formatters, status maps) can't use the hook — import the instance and call it directly:
import i18n from '../i18n/config'
export const prettyStatus = (s: string) => i18n.t(`status.${s}`)
The config above is the whole story. import './i18n/config' in main.tsx; useTranslation everywhere.
@cloudflare/vite-plugin / Worker)Same config, but import './i18n/config' in both the worker entry (worker.tsx) and the client entry (client.tsx) so the global i18next instance is initialised on each side before <App/> renders. The locale JSON is bundled into the Worker (a static import, not a fetch — the Worker never has to fetch its own assets). The shared <App/> uses useTranslation normally.
Server components cannot call useTranslation, and they must not import initReactI18next: it calls React's createContext, which throws during RSC page-data collection ((0 , Y.createContext) is not a function at build). Use a plain i18next instance and a fixed translator instead.
app/i18n/config.ts:
import { createInstance } from 'i18next'
import en from './en.json'
// Typed tokens — same augmentation as the SPA config; `getT()('key')` is
// checked against en.json's flattened dot-path keys.
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation'
resources: {
translation: typeof en
}
}
}
export const supportedLocales = ['en'] as const
export type Locale = (typeof supportedLocales)[number]
export const defaultLocale: Locale = 'en'
const i18n = createInstance()
i18n.init({
resources: { en: { translation: en } },
lng: defaultLocale,
fallbackLng: defaultLocale,
interpolation: { escapeValue: false },
})
export function getT(locale: Locale = defaultLocale) {
return i18n.getFixedT(locale)
}
export default i18n
import { getT } from './i18n/config'
export default function Page() {
const t = getT()
return <h1>{t('page.title')}</h1>
}
This works in both output: 'export' (static) and dynamic SSR — the static export pre-renders translated HTML at build time.
A 'use client' component that needs the hook uses the standard initReactI18next instance behind an I18nextProvider, kept separate from the server app/i18n/config.ts. Most starter pages are server components and never need this.
i18n/de.json mirroring en.json's keys.import de from './de.json', add 'de' to supportedLocales, and register it as de: { translation: de satisfies typeof en }. The satisfies typeof en makes an incomplete or drifted de.json a compile error — which is what blocks a deploy on missing i18n (see below).detectLocale already resolves /de/...; wire the locale segment into routing so /de renders the German tree (the default locale stays prefix-free).public/ or fetch them at runtime.initReactI18next into a Next.js server component or any module a server component imports.documentation
Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.
testing
Use when adding authorization checks to Pikku functions or routes — pikkuPermission, pikkuAuth, per-function permissions, pattern-based permissions, or understanding OR/AND permission logic. TRIGGER when: user wants to restrict who can call a function, check resource ownership, add role-based access, or understand where permission checks belong. DO NOT TRIGGER when: user asks about middleware or request interception (use pikku-middleware), authentication strategies (use pikku-security), or session management.
testing
Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware (including service-to-service bearer auth), per-route middleware, session-setting middleware, or understanding middleware execution order and priority. TRIGGER when: user wants middleware on some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about permissions/authorization checks (use pikku-permissions), auth strategies like authBearer/authCookie (use pikku-security), or deployment.
documentation
Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `pikku create`, `git clone <template>`, or the user says "I cloned the kanban template / starter / template"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.