Skip to content

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:

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-TOKEN cookie is present, its value is sent back as an X-CSRF-TOKEN header. 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/refresh itself 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:

ini
VITE_USE_MOCK_AUTH=true
VITE_DEMO_MODE=true

Delete 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:

ini
VITE_API_TARGET=http://localhost:8000

It 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:

ini
VITE_API_BASE_URL=https://api.yourdomain.com

If 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 ​

json
{
  "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:

json
{
  "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:

ts
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.

bash
node scripts/dev-api.mjs

It listens on port 3001. Point the proxy at it in .env.local and restart the dev server:

ini
VITE_API_TARGET=http://127.0.0.1:3001
VITE_PRODUCT_REAL_API=true

The 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:

MethodPathExpected
POST/auth/loginThe session cookie, and { user, permissions }.
POST/auth/registerThe same.
POST/auth/logoutClears the cookie.
POST/auth/refreshA fresh session cookie, or 401.
GET/auth/me{ user, permissions }, or 401 when signed out.
POST/auth/forgot-passwordAccepted. Takes { email }.
POST/auth/reset-passwordTakes { token, password }. Does not sign the user in.
POST/auth/unlockTakes { password }, for the lock screen.
POST/auth/two-factorTakes { 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 ​