Nuxt 4 + Laravel API: The Full-Stack Setup I Actually Ship
NuxtLaravelFull-StackAPI

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

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.

All Posts
Mokammel Tanvir

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.