Connect your API
One axios instance, one proxy setting, and one error shape. Nothing else has to change.
The axios instance
Every request in the template goes through src/shared/lib/axios.ts:
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15_000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
})withCredentials: true is the load-bearing line. The session is an HTTP-only cookie the browser sends and receives on its own, so no token is ever held in JavaScript.
Two interceptors are attached:
- CSRF. If an
XSRF-TOKENcookie is present, its value is sent back as anX-CSRF-TOKENheader. The standard double-submit pattern; your backend sets the cookie. - 401 and refresh. A 401 triggers one call to
/auth/refresh, with every other 401 that lands meanwhile parked in a queue. On success the original requests are replayed. On failure the queue is rejected and the browser is sent to/auth/login. A request is only ever retried once, and/auth/refreshitself is never retried.
Point it at your backend
Turn off the demo backend
The zip ships a .env.development that turns the demo backend on and pre-fills the sign-in form:
VITE_USE_MOCK_AUTH=true
VITE_DEMO_MODE=trueDelete both lines. The mock attaches only on the exact string true, and when it is off nothing about it remains in the request path. VITE_DEMO_MODE only fills in the demo credentials, which mean nothing to your backend.
Set the proxy target
The browser calls /api/... on its own origin. In development, Vite forwards that to your backend:
VITE_API_TARGET=http://localhost:8000It defaults to http://localhost:8000. The prefix is not stripped, so /api/products arrives at your server as /api/products.
Going through the proxy is not cosmetic. A Set-Cookie from another origin is dropped by the browser, so a cross-origin backend cannot sign anyone in. The proxy makes the call same-origin.
Or call an absolute URL
If your API lives on its own domain in production and sends the right CORS and cookie headers, set the base URL instead:
VITE_API_BASE_URL=https://api.yourdomain.comIf you serve the build with the shipped nginx.conf, put the same origin in its Content Security Policy. See Deployment.
Remove the demo data
Each demo module carries a __mock__/ folder, imported for its side effects in src/main.ts. Every one attaches only while VITE_USE_MOCK_AUTH is true, so they are inert in production, but a module you have made yours does not need its mock. Delete the folder and its line in main.ts.
What your backend has to answer
A list
{
"data": [],
"total": 240,
"page": 1,
"perPage": 20,
"totalPages": 12
}useCrud reads data and total. If your list sits somewhere else in the response, say so in rowsPath when you generate the module, and adjust the call.
A validation failure
This is the one shape the template insists on. Answer 422 with a flat list of field errors:
{
"errors": [
{ "field": "sku", "message": "That SKU is already taken" },
{ "field": "price", "message": "Price must be between 0 and 1,000,000" }
]
}useApiError returns that array instead of raising a toast, and the form puts each message under its own input:
import { useApiError } from '@shared/composables/useApiError'
const { handleError } = useApiError()
async function save(values: ProductForm) {
try {
await productsApi.create(values)
}
catch (error) {
const fieldErrors = handleError(error)
if (fieldErrors)
serverErrors.value = fieldErrors
}
}SchemaForm takes that list as its serverErrors prop and matches each entry to its field by field.
Everything else
useApiError turns every other status into one toast, in the reader's language: 400 shows your message, 403 forbidden, 404 not found, 429 a warning, 500 to 504 a server error, a timeout when axios aborts, and a network message when there is no response at all. No module writes its own toast.error for a failed request.
Try it against a real server
The template ships a small Node backend so you can watch the whole path work before your own API exists. It has no dependencies and serves /api/products.
node scripts/dev-api.mjsIt listens on port 3001. Point the proxy at it in .env.local and restart the dev server:
VITE_API_TARGET=http://127.0.0.1:3001
VITE_PRODUCT_REAL_API=trueThe second line detaches the products module's demo data so its requests reach the stub, while the auth mock keeps signing you in.
It validates like a real backend, and deliberately one rule stricter than the client: a duplicate sku is accepted by the form and refused by the server with a 422. That is the case worth seeing, because it is the one a client-side schema can never catch.
The auth endpoints
Nine paths, all under the /api base, from src/modules/auth/api:
| Method | Path | Expected |
|---|---|---|
POST | /auth/login | The session cookie, and { user, permissions }. |
POST | /auth/register | The same. |
POST | /auth/logout | Clears the cookie. |
POST | /auth/refresh | A fresh session cookie, or 401. |
GET | /auth/me | { user, permissions }, or 401 when signed out. |
POST | /auth/forgot-password | Accepted. Takes { email }. |
POST | /auth/reset-password | Takes { token, password }. Does not sign the user in. |
POST | /auth/unlock | Takes { password }, for the lock screen. |
POST | /auth/two-factor | Takes { code }. |
Keep any path you add to lowercase letters and hyphens. The mock matches /auth/[a-z-]+, so a path such as /auth/2fa would slip past it and hit the network.
permissions is a flat list of { action, subject } pairs, optionally with conditions, and it becomes the CASL ability that the route guard, the sidebar and v-permission all read. See Permissions.
Where to go next
- Permissions — what to put in the permission list.
- Add a module — generate the page that calls this API.
- Deployment — the environment variables in production.