How to Build a Full CRUD App With Vue 3 + Laravel API in 2026 — The Tutorial That Actually Finishes

Axios with interceptors, Pinia for state, Vue Router for navigation, form validation with VeeValidate, error handling, loading states, and the component structure that does not fall apart when requirements change — a complete working app, not a half-finished demo.


Most Vue + Laravel CRUD tutorials end at the same place: a list page, a create form, maybe an edit form if the tutorial had time. Nobody handles what happens when the API returns a validation error mid-submit, nobody shows a loading state that isn’t a five-minute afterthought bolted on at the end, and nobody structures the components in a way that survives the second feature getting added — because there’s rarely a second feature in a tutorial. Six weeks into a real project built the same way, adding one new field to a form means editing four different places that all silently assumed the form would never change, because nothing about the original structure was built to be extended, just to be demoed once.

This build uses a genuinely decoupled architecture — a separate Laravel API (Sanctum-authenticated, JSON responses) and a standalone Vue 3 SPA talking to it over Axios, with Vue Router handling all client-side navigation. This is a different, and legitimate, alternative to an Inertia-based monolith — the right call when the frontend needs to be deployed independently, consumed by more than one client, or built by a team that wants a clean API boundary regardless of what’s on the other side of it. This post is the complete version: Axios configured once with interceptors instead of repeated per-request boilerplate, Pinia holding the actual CRUD state, Vue Router with real navigation guards, VeeValidate wired to the same validation rules the backend enforces, loading and error states treated as first-class parts of every request instead of an afterthought, and a component structure specifically built to survive the next three features, not just the first one.


Axios, Configured Once

The mistake most tutorials make: constructing a new Axios call, with the base URL, headers, and error handling repeated, in every single component that needs to talk to the API. This works for a five-component demo and becomes unmaintainable the moment fifteen components all need the auth token attached the same way.

// src/lib/api.ts
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  headers: { Accept: 'application/json' },
})

api.interceptors.request.use((config) => {
  const auth = useAuthStore()
  if (auth.token) {
    config.headers.Authorization = `Bearer ${auth.token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const auth = useAuthStore()
      auth.clearSession()
      router.push({ name: 'login' })
    }

    return Promise.reject(error)
  }
)

Every component in this app imports this one configured instance — never a raw axios.get(...) call constructed locally. The request interceptor means no component ever has to remember to attach the bearer token; the response interceptor means an expired or invalid token gets handled in exactly one place, globally, instead of every component individually checking for a 401 and redirecting on its own. This is the single highest-leverage decision in the entire build — the difference between adding auth token handling once and adding it fifteen times, and between fixing a session-expiry bug in one file versus finding every place it was handled slightly differently.


Pinia — The Actual CRUD State, Not Just UI Preferences

For a CRUD app, Pinia holds the resource data itself — not because Pinia is required for state management in Vue, but because the same list of items needs to be read and mutated from multiple components (a list view, a detail view, a create form that should update the list without a full refetch) without prop-drilling it through every layer.

// src/stores/posts.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { Post, PostPayload } from '@/types'

export const usePostsStore = defineStore('posts', () => {
  const posts = ref<Post[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function fetchAll() {
    loading.value = true
    error.value = null

    try {
      const { data } = await api.get<{ data: Post[] }>('/posts')
      posts.value = data.data
    } catch (e) {
      error.value = 'Failed to load posts.'
      throw e
    } finally {
      loading.value = false
    }
  }

  async function create(payload: PostPayload): Promise<Post> {
    const { data } = await api.post<{ data: Post }>('/posts', payload)
    posts.value.unshift(data.data) // update local state — no refetch needed
    return data.data
  }

  async function update(id: number, payload: PostPayload): Promise<Post> {
    const { data } = await api.put<{ data: Post }>(`/posts/${id}`, payload)
    const index = posts.value.findIndex(p => p.id === id)
    if (index !== -1) posts.value[index] = data.data
    return data.data
  }

  async function remove(id: number): Promise<void> {
    await api.delete(`/posts/${id}`)
    posts.value = posts.value.filter(p => p.id !== id)
  }

  return { posts, loading, error, fetchAll, create, update, remove }
})

The pattern worth internalizing: every mutation (create, update, remove) updates the local posts array directly from the API response, rather than calling fetchAll() again after every write. This is both faster — no redundant round trip just to re-fetch data the create/update response already returned — and it’s what makes a list page feel instantly responsive after a create or edit, instead of showing a loading spinner for a full refetch every single time. The store is the single source of truth for this resource across the entire app; a list component and a detail component both reading from the same posts ref never risk showing different, out-of-sync versions of the same data.


Vue Router — Navigation Guards That Actually Guard

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { guest: true } },
    {
      path: '/posts',
      name: 'posts.index',
      component: () => import('@/views/posts/Index.vue'),
      meta: { requiresAuth: true },
    },
    {
      path: '/posts/:id/edit',
      name: 'posts.edit',
      component: () => import('@/views/posts/Edit.vue'),
      meta: { requiresAuth: true },
      props: true,
    },
  ],
})

router.beforeEach((to) => {
  const auth = useAuthStore()

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  if (to.meta.guest && auth.isAuthenticated) {
    return { name: 'posts.index' }
  }
})

Every lazy-loaded route (component: () => import(...)) is deliberate, not incidental — it means the login page’s JavaScript bundle doesn’t ship to a user who’s already authenticated and headed straight to the posts list, and vice versa. The redirect query param on the guard’s return is the detail most tutorials skip: without it, a user who gets bounced to /login because their session expired mid-navigation lands back on a generic dashboard after logging in again, not back on the specific page they were actually trying to reach — a small thing that’s noticeably annoying the first time a real user hits it.


VeeValidate — One Set of Rules, Matching the Backend

The gap most tutorials leave open: frontend validation and backend validation, written independently, drifting out of sync the moment one gets updated without the other. A backend Form Request requiring a title under 255 characters and a frontend form with no length check at all means the user finds out about the limit only after a submit fails — bad UX for a rule the frontend could have caught instantly.

// app/Http/Requests/StorePostRequest.php — the backend source of truth
public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string', 'min:10'],
    ];
}
// src/validation/postSchema.ts — mirrors the backend rules deliberately
import { z } from 'zod'

export const postSchema = z.object({
  title: z.string().min(1, 'Title is required').max(255, 'Title must be under 255 characters'),
  body: z.string().min(10, 'Body must be at least 10 characters'),
})
<!-- src/views/posts/Create.vue -->
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { postSchema } from '@/validation/postSchema'
import { usePostsStore } from '@/stores/posts'
import { useRouter } from 'vue-router'
import { ref } from 'vue'

const posts = usePostsStore()
const router = useRouter()
const submitError = ref<string | null>(null)

const { defineField, handleSubmit, errors, isSubmitting } = useForm({
  validationSchema: toTypedSchema(postSchema),
})

const [title, titleAttrs] = defineField('title')
const [body, bodyAttrs] = defineField('body')

const onSubmit = handleSubmit(async (values) => {
  submitError.value = null

  try {
    const post = await posts.create(values)
    router.push({ name: 'posts.index' })
  } catch (e: any) {
    if (e.response?.status === 422) {
      // Backend caught something the frontend schema missed — show it
      submitError.value = Object.values(e.response.data.errors).flat().join(' ')
    } else {
      submitError.value = 'Something went wrong. Please try again.'
    }
  }
})
</script>

<template>
  <form @submit="onSubmit">
    <input v-model="title" v-bind="titleAttrs" type="text" placeholder="Title" />
    <span v-if="errors.title" class="text-red-600 text-sm">{{ errors.title }}</span>

    <textarea v-model="body" v-bind="bodyAttrs" placeholder="Body" />
    <span v-if="errors.body" class="text-red-600 text-sm">{{ errors.body }}</span>

    <p v-if="submitError" class="text-red-600 text-sm">{{ submitError }}</p>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? 'Saving...' : 'Save' }}
    </button>
  </form>
</template>

The 422 branch in the catch block is the detail almost every tutorial skips, and it’s the one that actually matters in production. Frontend validation is a UX convenience, not a security boundary or a guarantee — the backend’s Form Request is the real source of truth, and it can reject something the frontend schema missed (a uniqueness constraint the frontend has no way to check client-side, a business rule enforced only server-side). Handling the 422 response explicitly, surfacing the backend’s actual error messages rather than a generic “something went wrong,” is what keeps the frontend schema from being a false promise that “if it passes client-side, it’ll definitely succeed.”


Loading and Error States as First-Class Citizens, Not an Afterthought

<!-- src/views/posts/Index.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePostsStore } from '@/stores/posts'
import { storeToRefs } from 'pinia'

const postsStore = usePostsStore()
const { posts, loading, error } = storeToRefs(postsStore)

onMounted(() => postsStore.fetchAll())
</script>

<template>
  <div v-if="loading" class="flex justify-center py-12">
    <Spinner />
  </div>

  <div v-else-if="error" class="text-center py-12">
    <p class="text-red-600 mb-3">{{ error }}</p>
    <button @click="postsStore.fetchAll()" class="text-indigo-600 underline">
      Try again
    </button>
  </div>

  <div v-else-if="posts.length === 0" class="text-center py-12 text-gray-500">
    No posts yet. Create your first one to get started.
  </div>

  <ul v-else class="divide-y">
    <li v-for="post in posts" :key="post.id" class="py-4">
      <RouterLink :to="{ name: 'posts.edit', params: { id: post.id } }">
        {{ post.title }}
      </RouterLink>
    </li>
  </ul>
</template>

Four distinct states, all handled explicitly: loading, error, empty, and populated. Most tutorial CRUD apps only ever render the fourth one, because a demo always has data and a demo’s network is always fast and reliable. A real user hits all four — the first load before data arrives, a network blip that fails the request, a genuinely empty list before anything’s been created, and the normal case. Skipping any of the first three doesn’t make the app simpler; it makes the app broken under the exact conditions that happen constantly in real usage and never happen in a five-minute local demo.

The storeToRefs call is a small but common Pinia mistake worth flagging explicitly. Destructuring const { posts, loading, error } = postsStore directly, without storeToRefs, breaks reactivity — those properties become plain, non-reactive values frozen at the moment of destructuring, and the template silently stops updating when the store’s state changes. storeToRefs preserves reactivity on destructured state properties specifically (methods like fetchAll don’t need it and shouldn’t be wrapped in it) — this is a mistake that’s completely invisible until state actually changes and the UI doesn’t follow, which is exactly the kind of bug a five-minute demo, run once, never has time to surface.


Component Structure That Survives a Second Feature

src/
  lib/
    api.ts                    -- one Axios instance, interceptors included
  stores/
    auth.ts
    posts.ts                  -- CRUD state, one store per resource
  router/
    index.ts                  -- guards live here, not scattered per-component
  validation/
    postSchema.ts             -- mirrors backend rules deliberately
  views/
    posts/
      Index.vue                -- list: loading/error/empty/populated states
      Create.vue
      Edit.vue
  components/
    posts/
      PostForm.vue              -- shared between Create.vue and Edit.vue
    ui/
      Spinner.vue
      ErrorBanner.vue
  types/
    index.ts                   -- Post, PostPayload, shared across store + views

The detail that keeps this from falling apart at the second feature: PostForm.vue is shared between Create.vue and Edit.vue, not duplicated.

<!-- src/components/posts/PostForm.vue -->
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { postSchema } from '@/validation/postSchema'
import type { Post } from '@/types'

const props = defineProps<{ initialValues?: Partial<Post> }>()
const emit = defineEmits<{ submit: [values: { title: string; body: string }] }>()

const { defineField, handleSubmit, errors, isSubmitting } = useForm({
  validationSchema: toTypedSchema(postSchema),
  initialValues: props.initialValues,
})

const [title, titleAttrs] = defineField('title')
const [body, bodyAttrs] = defineField('body')

const onSubmit = handleSubmit((values) => emit('submit', values))
</script>

<template>
  <form @submit="onSubmit">
    <input v-model="title" v-bind="titleAttrs" type="text" placeholder="Title" />
    <span v-if="errors.title">{{ errors.title }}</span>

    <textarea v-model="body" v-bind="bodyAttrs" placeholder="Body" />
    <span v-if="errors.body">{{ errors.body }}</span>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? 'Saving...' : 'Save' }}
    </button>
  </form>
</template>

The version of this tutorial that duplicates the form markup between a Create.vue and an Edit.vue isn’t wrong on day one — it’s wrong on the day a new field gets added to the post model, and the person adding it has to remember there are two forms to update, in two different files, that happen to look almost identical. Extracting PostForm.vue once, with initialValues as an optional prop distinguishing “creating fresh” from “editing existing,” means a new field is added in exactly one place, and both the create and edit flows pick it up automatically.


The Complete Flow

User submits the create form
  → VeeValidate runs postSchema client-side — catches most issues instantly
  → posts.create() called on the Pinia store
  → Axios instance attaches the bearer token via request interceptor
  → POST /posts hits Laravel — Form Request re-validates server-side, the
    actual source of truth
  → On success: response data pushed directly into the store's local
    posts array — no refetch, list updates instantly
  → On 422: backend validation errors surfaced in the form, even though
    they passed the frontend schema (a uniqueness check, a business rule)
  → On 401 (session expired mid-flow): response interceptor catches it
    globally, clears the session, redirects to /login with a return path

The One Rule

Every decision in this build is aimed at the same failure mode: a CRUD app that works perfectly in a five-minute demo and falls apart the moment real usage or a second feature touches it. Axios repeated per-component instead of configured once. A store that refetches instead of updating local state. A form with no explicit 422 handling, silently trusting the frontend schema as if it were the whole truth. A list view with no loading, error, or empty state, because the demo always had fast data. A form duplicated instead of shared, waiting for the next field to be added twice, inconsistently, in two files. None of these show up in the demo. All of them show up in the first real week — which is the entire difference between a tutorial that ends and an app that actually finishes.

Leave a Reply

Your email address will not be published. Required fields are marked *