diff --git a/CLAUDE.md b/CLAUDE.md
index e9bd45a..8d2f150 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,7 +32,8 @@ These are the rules that must not be forgotten or looked up — they're the ones
- Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders
- Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server
- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously
-- Never edit `assets/js/_pages.ts` or `assets/js/_ssr_pages.ts` — they're auto-generated
+- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
+- **Frontend paths come from the `routes` helper, never string literals.** `assets/js/routes.ts` is generated from the Phoenix router by `mix routes.gen` (run in `assets.build`/`assets.deploy` + the dev watcher; `mix routes.gen --check` in `precommit` fails the build on drift). Use `routes.login()`, `routes.settingsEmail()`, etc. for every internal URL on the frontend (``, `useForm().post(...)`, `router.visit/delete(...)`) — the TS path-builder is the frontend counterpart to the server's `~p` sigil and keeps the two in sync. Names are the camelCased path (`/settings/email/apply-change` → `settingsEmailApplyChange`, `/` → `root`); `:param` segments become typed args and every builder takes an optional query object (`routes.confirmEmail({ token })`). Adding/removing a route + rebuilding regenerates the file; never hand-edit it
- **Validation errors must redirect, not re-render.** On `{:error, changeset}` always use `conn |> assign_errors(changeset) |> redirect(to: ~p"/current-page")`. Do **not** use `put_status(:unprocessable_entity) |> render_inertia(...)` — Inertia updates the browser URL to the POST/PUT target when you re-render, which is both wrong UX and a regression risk. The Inertia plug flashes errors through the session across the redirect, so the form still shows them
- Use the `~p"/..."` sigil (verified routes) for every internal URL — in controllers, tests, and anywhere else in Elixir. Never write raw route strings; compile-time verification catches typos and broken links
- **JSON serialisation goes through `*_json.ex` view modules**, never ad-hoc serializer modules (no `*Props`, `*Serializer`, or per-controller helpers). One module per resource at `lib/elixir_react_starter_web/controllers/_json.ex` (e.g. `ElixirReactStarterWeb.PostJSON`) exposes `index/1` and `show/1` (the Phoenix 1.8 equivalents of `render_many`/`render_one`) that both delegate to a single `data/2`. Inertia callers use `MyJSON.data(record, viewer)` directly inside `assign_prop`; JSON API endpoints use `index`/`show` via `render`. This keeps the wire shape for a resource in one place so multiple callers can't drift
diff --git a/assets/biome.json b/assets/biome.json
index 26e26b0..0e7f7ed 100644
--- a/assets/biome.json
+++ b/assets/biome.json
@@ -14,7 +14,8 @@
"!**/build/",
"!**/server/static/**",
"!js/_pages.ts",
- "!js/_ssr_pages.ts"
+ "!js/_ssr_pages.ts",
+ "!js/routes.ts"
],
"indentStyle": "space",
"indentWidth": 2,
diff --git a/assets/build/watch-routes.js b/assets/build/watch-routes.js
new file mode 100644
index 0000000..cc453c9
--- /dev/null
+++ b/assets/build/watch-routes.js
@@ -0,0 +1,30 @@
+// Watches the Phoenix router for changes and regenerates the typed frontend
+// route table (assets/js/routes.ts) via `mix routes.gen`. Used as a Phoenix
+// dev watcher so routes.ts can't go stale while the dev server is running.
+//
+// Router edits are rare, so shelling out to `mix` (which boots a second BEAM)
+// on change is an acceptable cost — and it reuses the exact same generator
+// that assets.build and precommit use, so there's only one source of truth.
+
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+
+const ROOT = path.join(__dirname, '..', '..');
+const ROUTER = path.join(ROOT, 'lib', 'elixir_react_starter_web', 'router.ex');
+
+function regenerate() {
+ try {
+ execFileSync('mix', ['routes.gen'], { cwd: ROOT, stdio: 'inherit' });
+ } catch (_) {
+ // errors already printed by the child process
+ }
+}
+
+// Initial generation so a freshly-started server is always in sync.
+regenerate();
+
+// fs.watch on the single router file is enough — every route lives there.
+require('node:fs').watch(ROUTER, () => regenerate());
+
+// Keep process alive
+process.stdin.resume();
diff --git a/assets/js/components/LocaleSelector.tsx b/assets/js/components/LocaleSelector.tsx
index 5e5f591..ae277e2 100644
--- a/assets/js/components/LocaleSelector.tsx
+++ b/assets/js/components/LocaleSelector.tsx
@@ -1,5 +1,6 @@
import { router, usePage } from '@inertiajs/react';
import { Check } from 'lucide-react';
+import { routes } from '../routes';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './DropdownMenu';
interface LocaleSelectorProps {
@@ -18,7 +19,7 @@ export default function LocaleSelector({ className = '' }: LocaleSelectorProps)
function handleSelect(code: string) {
if (code !== locale) {
router.put(
- '/locale',
+ routes.locale(),
{ locale: code },
{
preserveScroll: true,
diff --git a/assets/js/layouts/AppLayout.tsx b/assets/js/layouts/AppLayout.tsx
index 1696c24..b0df3f9 100644
--- a/assets/js/layouts/AppLayout.tsx
+++ b/assets/js/layouts/AppLayout.tsx
@@ -13,6 +13,7 @@ import {
import Link from '../components/Link';
import LocaleSelector from '../components/LocaleSelector';
import ThemeToggle from '../components/ThemeToggle';
+import { routes } from '../routes';
import type { CurrentUser } from '../types';
interface AppLayoutProps {
@@ -47,7 +48,7 @@ export default function AppLayout({ title, children }: AppLayoutProps) {
-
+
ElixirReactStarter
- router.visit('/settings')}>
+ router.visit(routes.settings())}>
{t('common.settings')}
- router.delete('/logout')}>
+ router.delete(routes.logout())}>
{t('common.logout')}
diff --git a/assets/js/pages/client/Settings.tsx b/assets/js/pages/client/Settings.tsx
index d511be8..1bd5ec4 100644
--- a/assets/js/pages/client/Settings.tsx
+++ b/assets/js/pages/client/Settings.tsx
@@ -13,6 +13,7 @@ import {
import Button from '../../components/Button';
import { inputClass } from '../../components/ui';
import AppLayout from '../../layouts/AppLayout';
+import { routes } from '../../routes';
export default function Settings() {
const { t } = useTranslation();
@@ -48,7 +49,7 @@ function ChangeEmailSection() {