Vue 3 + Inertia
The frontend is Vue 3 (Composition API, <script setup>) driven by Inertia.js 3. There is no separate API — the Go server renders Inertia JSON props directly into Vue pages. The entry point is frontend/src/main.ts, built by Vite into dist/.
Entry point
Section titled “Entry point”The entry point is frontend/src/main.ts. With the @inertiajs/vite plugin, createInertiaApp() needs no custom resolve or setup — the plugin auto-discovers pages in frontend/src/pages/ and wires the Vue renderer.
import { createInertiaApp } from "@inertiajs/vue3";
createInertiaApp();No Fast Refresh preamble is needed — @vitejs/plugin-vue handles hot module replacement without a manual bootstrap.
Component structure
Section titled “Component structure”Every page is a .vue single-file component with <script setup lang="ts"> and <template>:
<script setup lang="ts">import { ref, computed } from "vue";import { Link, useForm } from "@inertiajs/vue3";import type { User } from "@lib/types";
interface Props { user?: User; success?: string; error?: string;}
const { user, success, error } = defineProps<Props>();</script>
<template> <!-- markup here --></template>Props and state — the real pattern
Section titled “Props and state — the real pattern”From frontend/src/pages/app/Profile.vue:
<script setup lang="ts">import { ref, computed } from "vue";import { Link, useForm } from "@inertiajs/vue3";import { getCSRFToken } from "@lib/utils/csrf";import type { User } from "@lib/types";
interface Props { user?: User; success?: string; error?: string;}
const { user, success, error } = defineProps<Props>();
// Form initialized from server propsconst profileForm = useForm("EditProfile", { name: user?.name ?? "", email: user?.email ?? "", avatar: user?.avatar ?? "",});
const showPassword = ref(false);
// Derived via computed — recomputes when user.avatar changesconst previewUrl = computed(() => user?.avatar ?? null);</script>previewUrl is computed, not a watch + manual ref assignment. If you reach for watch to compute a value, stop — it is almost always computed.
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/vue3 |
Pre-submit validation, fetch() integration, v-model reactive binding, programmatic submit |
useForm + <form @submit.prevent="..."> |
Default to useForm + <form> when unsure — it covers more cases.
Pattern A: <Form> — simple
Section titled “Pattern A: <Form> — simple”No v-model needed; Form collects values from name attributes. Least boilerplate.
<script setup lang="ts">import { Form } from '@inertiajs/vue3'</script>
<template> <Form action="/users" method="post"> <input type="text" name="name" /> <input type="email" name="email" /> <button type="submit">Create User</button> </Form></template>Slot props for processing state and errors:
<template> <Form action="/users" method="post" v-slot="{ errors, processing, wasSuccessful }"> <input type="text" name="name" /> <div v-if="errors.name">{{ errors.name }}</div> <button :disabled="processing"> {{ processing ? 'Creating...' : 'Create User' }} </button> </Form></template>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.vue:
<script setup lang="ts">import { ref } from "vue";import { Link, useForm } from "@inertiajs/vue3";
const form = useForm({ email: "", password: "",});
const showPassword = ref(false);
function submitForm() { form.post("/login");}</script>
<template> <form class="space-y-5" @submit.prevent="submitForm"> <input v-model="form.email" type="email" name="email" /> <input v-model="form.password" :type="showPassword ? 'text' : 'password'" name="password" /> <span v-if="form.errors.email">{{ form.errors.email }}</span> <button :disabled="form.processing">Sign in</button> </form></template>Update — give the form a unique key so its data and errors persist to history state:
<script setup lang="ts">import { useForm } from '@inertiajs/vue3'
const { user } = defineProps<{ user: User }>()
const form = useForm(`EditUser:${user.id}`, { name: user.name, email: user.email,})
function submit() { form.put(`/users/${user.id}`)}</script>
<template> <form @submit.prevent="submit"> <input v-model="form.name" /> <span v-if="form.isDirty">Unsaved changes</span> <button :disabled="form.processing">Save</button> </form></template>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 |
@submit.prevent on the <form> |
Prevents full page reload — Inertia sends an XHR instead |
File upload: fetch() + FormData, then form.put() |
Inertia forms cannot send files directly |
v-model="form.field" for two-way binding |
Vue reactivity — no manual onChange/value needed |
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.vue:
<script setup lang="ts">import { useForm } from "@inertiajs/vue3";import { getCSRFToken } from "@lib/utils/csrf";
const profileForm = useForm("EditProfile", { name: user?.name ?? "", avatar: user?.avatar ?? "",});
function handleAvatarChange(event: Event) { const target = event.target as HTMLInputElement; 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.avatar = data.url; profileForm.put("/app/profile"); } });}</script>Internal links: <Link>
Section titled “Internal links: <Link>”<script setup lang="ts">import { Link } from "@inertiajs/vue3";</script>
<template> <Link href="/app/profile">Profile</Link></template>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().