React 19 + Inertia
The frontend is React 19 driven by Inertia.js 3. There is no separate API — the Go server renders Inertia JSON props directly into React pages. The entry point is frontend/src/main.tsx, built by Vite into dist/.
Entry point and Fast Refresh
Section titled “Entry point and Fast Refresh”The entry point is frontend/src/main.tsx. With the @inertiajs/vite plugin, createInertiaApp() needs no custom resolve or setup — the plugin auto-discovers pages in frontend/src/pages/ and wires the React renderer.
import { createInertiaApp } from "@inertiajs/react";
// React Fast Refresh — must load before any component imports in dev.// @vitejs/plugin-react normally injects this preamble via transformIndexHtml,// but with Inertia the Go server serves HTML, so Vite never sees it.// We load it here manually so components load after refresh runtime is ready.//// Dynamic import is required: /@react-refresh is a Vite virtual module// that only exists in dev — a static import would break production builds.if (import.meta.env.DEV) { const { injectIntoGlobalHook } = await import("/@react-refresh"); injectIntoGlobalHook(window); window.$RefreshReg$ = () => {}; window.$RefreshSig$ = () => {};}
createInertiaApp();Props and state — the real pattern
Section titled “Props and state — the real pattern”From frontend/src/pages/app/Profile.tsx:
import { useState, useMemo } from "react";import { Link, useForm } from "@inertiajs/react";import { getCSRFToken } from "@lib/utils/csrf";import type { User } from "@lib/types";
interface Props { user?: User; success?: string; error?: string;}
export default function Profile({ user, success, error }: Props) { // Form initialized from server props const profileForm = useForm("EditProfile", { name: user?.name ?? "", email: user?.email ?? "", avatar: user?.avatar ?? "", });
const [showPassword, setShowPassword] = useState(false);
// Derived via useMemo — recomputes when user.avatar changes const previewUrl = useMemo(() => user?.avatar ?? null, [user?.avatar]);}previewUrl is useMemo, not a useEffect + manual state update. If you reach for useEffect to compute a value, stop — it is almost always useMemo or derived state.
Inertia form convention
Section titled “Inertia form convention”There are two ways to submit a form. Pick by what you need.
| Need | Use |
|---|---|
| Simple form — just collect data and submit | <Form> component from @inertiajs/react |
Pre-submit validation, fetch() integration, controlled inputs, programmatic submit |
useForm + <form onSubmit={...}> |
Default to useForm + <form> when unsure — it covers more cases.
Pattern A: <Form> — simple
Section titled “Pattern A: <Form> — simple”No onChange/value needed; Form collects values from name attributes. Least boilerplate.
import { Form } from '@inertiajs/react'
<Form action="/users" method="post"> <input type="text" name="name" /> <input type="email" name="email" /> <button type="submit">Create User</button></Form>Render props for processing state and errors:
<Form action="/users" method="post"> {({ errors, processing, wasSuccessful }) => ( <> <input type="text" name="name" /> {errors.name && <div>{errors.name}</div>} <button disabled={processing}> {processing ? 'Creating...' : 'Create User'} </button> </> )}</Form>Pattern B: useForm + <form> — validation and control
Section titled “Pattern B: useForm + <form> — validation and control”Auto-tracks processing, errors, isDirty, wasSuccessful. Allows pre-submit validation and fetch() integration.
Create — from frontend/src/pages/auth/Login.tsx:
import { useState } from "react";import { Link, useForm } from "@inertiajs/react";
export default function Login() { const form = useForm({ email: "", password: "", });
function submitForm(e: React.FormEvent) { e.preventDefault(); form.post("/login"); }
return ( <form className="space-y-5" onSubmit={submitForm}> <input value={form.data.email} onChange={(e) => form.setData("email", e.target.value)} type="email" name="email" /> <input value={form.data.password} onChange={(e) => form.setData("password", e.target.value)} type="password" name="password" /> {form.errors.email && <span>{form.errors.email}</span>} <button disabled={form.processing}>Sign in</button> </form> );}Update — give the form a unique key so its data and errors persist to history state:
import { useForm } from '@inertiajs/react'
export default function EditUser({ user }: { user: User }) { const form = useForm(`EditUser:${user.id}`, { name: user.name, email: user.email, })
function submit(e: React.FormEvent) { e.preventDefault() form.put(`/users/${user.id}`) }
return ( <form onSubmit={submit}> <input value={form.data.name} onChange={(e) => form.setData("name", e.target.value)} /> {form.isDirty && <span>Unsaved changes</span>} <button disabled={form.processing}>Save</button> </form> )}Key rules (both patterns)
Section titled “Key rules (both patterns)”| Rule | Why |
|---|---|
form.post() for create, form.put() / form.patch() for update |
Correct HTTP method; server knows intent |
Unique key for edit forms: useForm('EditUser:${id}', data) |
Persists form data + errors to history state |
disabled={form.processing} or disabled={processing} |
Prevent double-submit |
form.errors.field or errors.field |
Server validation errors auto-populate |
e.preventDefault() in the useForm submit handler |
Prevents full page reload — Inertia sends an XHR instead |
File upload: fetch() + FormData, then form.put() |
Inertia forms cannot send files directly |
className not class |
JSX syntax — class is a reserved word |
htmlFor not for |
JSX syntax — for is a reserved word |
File upload + form save (two-step)
Section titled “File upload + form save (two-step)”Avatar upload uses fetch() + FormData for the file, then form.put() to persist the resulting URL. From frontend/src/pages/app/Profile.tsx:
import { useForm } from "@inertiajs/react";import { getCSRFToken } from "@lib/utils/csrf";
const profileForm = useForm("EditProfile", { name: user?.name ?? "", avatar: user?.avatar ?? "",});
function handleAvatarChange(event: React.ChangeEvent<HTMLInputElement>) { const target = event.target; const file = target.files?.[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); fetch("/app/upload", { method: "POST", headers: { "X-XSRF-TOKEN": getCSRFToken() }, body: formData, }) .then((response) => response.json()) .then((data) => { if (data.success && data.url) { profileForm.setData("avatar", data.url); profileForm.put("/app/profile"); } });}Internal links: <Link>
Section titled “Internal links: <Link>”import { Link } from "@inertiajs/react";
<Link href="/app/profile">Profile</Link>Exceptions — plain <a> without <Link>:
- OAuth links (
/auth/google,/auth/github) — these leave the app to a provider and come back; a full navigation is correct. - External links (
https://github.com/...) —<Link>only applies to same-origin routes.
CSRF for fetch()
Section titled “CSRF for fetch()”Inertia’s router (Axios) automatically reads the XSRF-TOKEN cookie and sends it as the X-XSRF-TOKEN header. Plain fetch() does not — so every manual fetch() to a CSRF-protected route (/app/*, /admin/*) must add the header explicitly.
export function getCSRFToken(): string { const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); return match ? decodeURIComponent(match[1]) : "";}Usage:
fetch("/app/upload", { method: "POST", headers: { "X-XSRF-TOKEN": getCSRFToken() }, body: formData,});If you forget the header on a fetch() to a protected route, the CSRFMiddleware rejects the request. Inertia form/router calls never need this — only raw fetch().