Nuxt 4 + Laravel API: The Full-Stack Setup I Actually Ship
How I structure a Nuxt 4 frontend with a Laravel REST API — auth composable, consistent response shape, error handling, and the lessons from two years of running this in production.
Mokammel Tanvir
Software Engineer
The Stack I Keep Coming Back To
Nuxt 4 on the frontend, Laravel 11 on the backend, Sanctum for auth. I've built on this combination for two years now across multiple projects, including SP360 at Smart Provider LLC.
It handles 90% of SaaS product needs without overengineering. This isn't a tutorial on what's possible — it's how I actually set it up.
The $fetch Wrapper
First thing I create in every project: a composable that wraps $fetch with auth headers, base URL, and a global 401 redirect. This keeps every component consistent without repeating setup code.
// app/composables/useApi.ts
export const useApi = () => {
const config = useRuntimeConfig()
return $fetch.create({
baseURL: config.public.apiBase,
credentials: 'include',
onRequest({ options }) {
const token = useCookie('XSRF-TOKEN')
if (token.value) {
options.headers = {
...options.headers,
'X-XSRF-TOKEN': token.value,
}
}
},
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
},
})
}
Sanctum Config That Actually Works
Cookie-based auth for the SPA. Tokens in localStorage are XSS targets — never do that for a web app.
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
'localhost,localhost:3000,yourdomain.com'
)),
// config/cors.php
'supports_credentials' => true,
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
The CSRF cookie request must happen before login. Miss this step and you get 419 errors.
async function login(credentials: { email: string; password: string }) {
await $fetch('/sanctum/csrf-cookie', { baseURL: apiBase, credentials: 'include' })
await $fetch('/api/login', { method: 'POST', body: credentials, credentials: 'include', baseURL: apiBase })
}
Consistent API Response Shape
Every Laravel controller in my projects returns the same JSON structure. This makes frontend error handling predictable across the entire app.
// Success
return response()->json(['data' => $resource, 'message' => 'Created successfully.']);
// Validation error — via Handler.php
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
On the Nuxt side:
try {
const { data, message } = await useApi()('/api/v1/projects', {
method: 'POST',
body: form,
})
toast.success(message)
} catch (error: any) {
formErrors.value = error.data?.errors ?? {}
}
useAsyncData Over useFetch
useFetch is convenient but gives less control over cache keys and when data is fetched. I prefer useAsyncData explicitly.
const { data: projects, pending } = await useAsyncData(
'projects-list',
() => useApi()('/api/v1/projects'),
{ default: () => [] }
)
What I Learned the Hard Way
Version your API from day one. /api/v1/ costs nothing to add now and saves painful migration work later when a route needs to change while old clients still depend on it.
Return consistent JSON shape from the first endpoint. Every time I've skipped this "just to ship faster," I've paid for it the next time I needed to handle an error in a new component.
This setup is running in production on SP360. I haven't had a reason to change the core pattern.

Mokammel Tanvir
Full-Stack Engineer · Laravel · Vue · WordPress · AI
Building web applications with Laravel, Vue/Nuxt, and WordPress — SaaS platforms, REST APIs, and AI-integrated workflows. Open to remote and hybrid opportunities.
