- Files:
kebab-case(e.g.my-component.tsx,user-profile.ts) - Components:
PascalCase(e.g.MyComponent,UserProfile) - Variables/functions:
camelCase - Routes/folders:
kebab-case
- Hybrid approach: Atomic design (
atoms/,molecules/,organisms/) for shared/reusable components, feature-based folders for page-specific code - Pages go in
src/app/(pages)/using route groups - Shared utilities in
src/lib/ - Shared UI primitives in
src/components/ui/(installed via the registry CLI documented inSKILLS.md)
- Server-first: Default to Server Components. Only add
'use client'when the component needs interactivity, hooks, or browser APIs - Split components when they have distinct responsibilities or become too large
- Props interfaces go in a separate
interfaces.tsfile when shared, or co-located when component-specific
src/components/ui/— Reserved exclusively for registry-installed UI primitives. Components here are added only via the registry CLI(s) and install-time rules documented inSKILLS.md— never hand-written or manually edited. No custom/hand-written component ever belongs inui/; custom work goes intoatoms/,molecules/,organisms/, or a page's_local/folder. Refer toSKILLS.mdfor the current list of supported registries and the priority order between them- Atoms (
src/components/atoms/) — Zero internal dependencies (no imports from molecules/organisms) - Molecules (
src/components/molecules/) — May depend only on atoms - Organisms (
src/components/organisms/) — May depend on atoms and/or molecules - Only shared components belong in
src/components/. "Shared" means the component is imported in more than one place — not merely rendered on multiple pages. A component imported once in a layout (even if that layout renders across many pages) is layout-specific, not shared
- Each page or layout that needs its own components, server actions, or styles gets a
_local/folder inside its route directory (e.g.app/(pages)/users/_local/) - The
_local/folder holds everything specific to that page/layout: components, server actions, CSS files, etc. - The underscore prefix ensures Next.js excludes it from routing
Organize _local/ by concern, not by file type:
interfaces.ts— Shared types and interfaces used across multiple files within this_local/mock-constants.ts(orconstants.ts) — Static data, enums, lookup maps. Will become API calls laterutil.ts— Pure utility functions (formatters, helpers) specific to this page*-actions.ts— Server actions ('use server')- Component files — One component per file; default export; arrow function expression
When a section (e.g. a tab) has its own interactive sub-components, give it a folder:
_local/
├── interfaces.ts
├── mock-constants.ts
├── util.ts
├── payment-actions.ts
├── payments-tabs.tsx ← thin shell, composes children
├── collections-tab/
│ ├── index.tsx ← server component (table)
│ └── refund-button.tsx ← 'use client' (only the interactive part)
├── payouts-tab/
│ ├── index.tsx
│ └── payout-button.tsx
└── revenue-summary-tab.tsx ← flat file when no interactivity needed
When a section is simple (no interactive sub-parts), keep it as a flat file — no folder needed.
- Push
'use client'to the deepest leaf possible — never mark a parent client just because a child needs interactivity - Extract only the interactive element (a button, a form input, a toggle) into its own tiny
'use client'file - Parent components stay server-rendered and compose client children
- A server component CAN import and render a client component from
src/components/ui/(e.g.Tabs,Sheet) — the server component itself does not need'use client'for this - Use library primitives (e.g.
SheetClosewithrenderprop) to avoiduseStateentirely when possible - Use
PropsWithChildrenfrom React instead of custom{ children: React.ReactNode }interfaces
- Tailwind utilities + project theme tokens (CSS variables) for theming — the tokens are defined in
globals.cssand shared by every primitive insrc/components/ui/, regardless of which registry it came from - Use the project's theme tokens (CSS variables) for colors, spacing, and design consistency — never hard-code colors or spacing that bypass the token system
globals.cssstays clean — only truly global styles belong there- When a page or layout needs unique/heavy styling, create a separate CSS file co-located inside that page's or layout's feature folder — never dump page-specific styles into
globals.css - Tailwind-first even in custom CSS files — use
@applyto compose Tailwind utilities into custom classes, and CSS variables for theming - Raw/custom CSS is acceptable as a last resort when Tailwind genuinely can't express the styling, but always attempt a Tailwind-based solution first
- Biome is the source of truth for JS, TS, JSX, TSX, JSON, JSONC, and CSS — linting, formatting, and import organization. Configured in
biome.json - Prettier is scoped to YAML only (
*.yaml/*.ymlat any depth). It exists because Biome 2.4 doesn't format YAML yet - Don't widen Prettier's scope. Three layers keep it YAML-only:
.prettierignoreignore-all + YAML allowlist- Script globs always pass
**/*.{yaml,yml}— neverprettier --write . - lint-staged matches only
*.{yaml,yml}for Prettier
- If a tool overlap appears (both touching the same file), resolve in favor of Biome and remove from Prettier scope
- When Biome ships YAML support, drop Prettier entirely
- Strict mode enabled
- Prefer
interfacefor object shapes,typefor unions/intersections - Never use
any— useunknownand narrow with type guards
- Server Actions + RSC: Fetch data in Server Components, mutate with Server Actions
- Minimal client-side state — avoid unnecessary
useState/useEffect - For global or shared client-side state, use Zustand
- Lift data fetching to the highest server boundary possible
Built on next-intl with a base + overlay model. Source lives in locales/ as YAML (committed); runtime is JSON + generated index.ts emitted into messages/ by scripts/gen-messages.ts (gitignored, never hand-edited).
- URL locales = regional variants only (e.g.
en-US,en-GB,es-ES,es-MX,hi-IN). Listed insrc/i18n/routing.ts - Base locales = per-language full dictionaries (e.g.
en,es,hi). Live inlocales/but never appear in a URL - Each URL locale maps to exactly one base locale via
baseOfinsrc/i18n/bases.ts - Default locale =
en-US.localePrefix: 'as-needed'— default locale serves on clean paths (/,/users); non-default keeps the prefix (/es-MX,/es-MX/users)./en-US/...307s back to/...to canonicalize.hreflangalt-links are auto-emitted for SEO - Switch to
localePrefix: 'always'for hard-split URLs per locale, or'never'to drop the[locale]segment entirely and detect from cookie/header only (see next-intl docs — trades SEO for clean URLs) - Always create a base even when only one region uses it today — future regions drop in as overlays without refactor
locales/ ← COMMITTED source (YAML)
en/ ← base (full dict)
home.yaml
loading.yaml
not-found.yaml
en-GB/ ← overlay (partial deltas only)
home.yaml ← only keys that differ from base
en-US/ ← overlay (may be empty — keep `.gitkeep`)
.gitkeep
es/ es-ES/ es-MX/
hi/ hi-IN/
messages/ ← GENERATED + GITIGNORED (JSON + index.ts per locale)
en/ en-GB/ en-US/ es/ es-ES/ es-MX/ hi/ hi-IN/
src/i18n/
routing.ts ← `locales` (regional) + `defaultLocale`
bases.ts ← `baseOf`, `loadBase`, `loadOverlay` (explicit static imports + type annotations that gate drift)
request.ts ← loads base + overlay, deep-merges per request
navigation.ts ← locale-aware `Link`, `redirect`, `usePathname`, `useRouter`
src/global.d.ts ← `MessageShape`, `MessageOverride`, `AppConfig.Messages`/`Locale` augment
src/proxy.ts ← `createMiddleware(routing)` (Next 16 renamed from `middleware.ts`)
scripts/gen-messages.ts ← compiles `locales/` → `messages/`; emits JSON + `index.ts` per dir
- Edit YAML in
locales/only. Never hand-edit anything undermessages/— the whole dir is regenerated by the compiler - One YAML file per namespace. Kebab-case filename → camelCase namespace key in generated
index.ts(e.g.not-found.yaml→notFound) - Nest freely inside a file (e.g.
home.dashboard.widgets.title) — next-intl resolves dotted paths - Empty overlay dirs keep a
.gitkeepso git tracks the locale
Messagestype insrc/global.d.tsistypeof en— theenbase is the canonical shape sourceuseTranslations('ns')andt('key')autocomplete + compile-error on typos- Drift gate:
loadBaseis typedRecord<BaseLocale, () => Promise<{ default: MessageShape }>>— any non-enbase missing a key (or with a different shape) fails tsc at theloadBaseassignment inbases.ts. Same for overlays viaMessageOverride - Never pass dynamic strings to
useTranslations/t— always string literals so TS can gate them
// Server component
import { getTranslations } from 'next-intl/server';
const t = await getTranslations('home');
<h1>{t('title')}</h1>;
// Client component
('use client');
import { useTranslations } from 'next-intl';
const t = useTranslations('home');For locale-aware navigation always import from ~/i18n/navigation (auto-prepends locale):
import { Link } from '~/i18n/navigation';
<Link href="/users">...</Link>;New region on an existing base (e.g. en-AU):
mkdir locales/en-AU— drop any delta YAMLs (e.g.locales/en-AU/home.yaml); if no deltas yet,touch locales/en-AU/.gitkeeprouting.ts→ push'en-AU'tolocalesbases.ts→ add'en-AU': 'en'tobaseOfand aloadOverlay['en-AU']entry
New language (e.g. fr):
mkdir locales/fr— author full YAML dict (one file per namespace, covering every keyenhas)mkdir locales/fr-FR(and any other regions) — delta YAMLs or.gitkeeprouting.ts→ add regional variants tolocalesbases.ts→ extendBaseLocaleunion, addbaseOfrows,loadBase['fr'],loadOverlayrows
Never add a base locale to routing.locales — bases are not URL-addressable. The compiler emits messages/<locale>/index.ts automatically on the next gen:i18n run.
bun run gen:i18n— compilelocales/**/*.yaml→messages/<locale>/*.json+messages/<locale>/index.tsbun run gen:i18n:watch— chokidar watcher onlocales/, regenerates per-locale index on changebun run dev— runsgen:i18nonce, thengen:i18n:watch+next devin parallelbun run build/bun run check-types— both prefix withgen:i18nsomessages/exists before Next/tsc reads it/messagesis fully gitignored; all committed source lives in/locales
This template ships i18n wired up. If your app is single-language, strip it instead of leaving it dormant.
- AI-assisted (recommended): invoke the
remove-i18nskill at.claude/skills/remove-i18n/SKILL.md. Ask Claude something like "remove i18n" or "drop localization" — the skill handles file deletes, import reverts,package.json/.gitignore/next.config.tscleanup, dep removal viabun remove, and a build verification. - Manual: follow the numbered steps in that same
SKILL.mdfile — it doubles as the human checklist. High-level: movesrc/app/[locale]/(pages)back tosrc/app/(pages), revertLayoutPropspath params, dropuseTranslationscalls, deletelocales/,messages/,src/i18n/,src/proxy.ts,src/global.d.ts,scripts/gen-messages.ts, revertnext.config.tsand thedev/build/check-typesscripts,bun remove next-intl yaml chokidar concurrently, strip the i18n.gitignoreblock, delete this section.
After removal, / should serve your app directly (no 307 to /en-US).
- Layout-level boundaries by default: Place
error.tsx,loading.tsx, andnot-found.tsxat layout boundaries - Add per-page boundaries only when a specific page needs distinct error/loading UX
- Use Sonner toasts for transient user-facing feedback
- Aggressive optimization: Use
next/imagefor all images,next/fontfor fonts - Dynamic imports (
next/dynamic) for heavy/below-fold components - Monitor and maintain strict bundle size awareness
- Lazy load non-critical content
- Vitest + React Testing Library for unit and component tests
- Playwright for end-to-end tests
- Unit/component test files co-located next to source
- E2E tests in a separate top-level test directory
- WCAG 2.1 AA compliance required
- Semantic HTML elements mandatory — no
<div>soup - ARIA labels on all interactive elements
- Full keyboard navigation support for all interactive components
- Conventional Commits: Use prefixes —
feat:,fix:,chore:,refactor:,docs:,test: - Commit subject: concise summary (imperative mood, ~50 chars)
- Commit body: detailed description of why and what changed
- Trunk-based development on
trunkbranch
- Pragmatic approach: Add packages when they save significant effort
- Prefer well-maintained, popular libraries with active communities
- Justify new additions — don't add a package for something trivially implementable