Claude Code for Nuxt 3: Full-Stack Vue with Server Routes and Nitro — Claude Skills 360 Blog
Blog / Development / Claude Code for Nuxt 3: Full-Stack Vue with Server Routes and Nitro
Development

Claude Code for Nuxt 3: Full-Stack Vue with Server Routes and Nitro

Published: June 19, 2026
Read time: 9 min read
By: Claude Skills 360

Nuxt 3 brings two things that Vue’s ecosystem previously lacked: a batteries-included full-stack framework and a server engine (Nitro) that deploys to virtually any platform. Claude Code understands Nuxt’s conventions — auto-imported composables, file-based routing, server routes, and the dual-rendering model that lets you choose SSR, SPA, or static per-route.

This guide covers building full-stack apps with Nuxt 3: server routes, data fetching, state management, and deployment.

Nuxt 3 Project Setup

CLAUDE.md for Nuxt Projects

## Nuxt 3 Project

- Framework: Nuxt 3 with TypeScript
- UI: Tailwind CSS via @nuxtjs/tailwindcss
- State: Pinia (auto-imported)
- Database: server/utils/db.ts (Drizzle ORM, PostgreSQL)
- Auth: nuxt-auth-utils (server-side sessions)

## Key conventions
- Pages: pages/ (auto-routed)
- Components: components/ (auto-imported — no import statements needed)
- Composables: composables/ (auto-imported — useFoo, useBar)
- Server routes: server/api/ and server/routes/
- Middleware: middleware/ (route guards)
- Utils: utils/ (shared helpers, also auto-imported)

## Rendering per route
- Default: SSR (server-side rendered, good for SEO)
- Specific pages use definePageMeta({ ssr: false }) for SPA behavior
- Static: set routeRules in nuxt.config.ts

## Data fetching
- In pages/components: useFetch('/api/...') or useAsyncData()
- Do NOT use fetch() directly in <script setup> — it won't SSR correctly

Nuxt Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  
  modules: [
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
    'nuxt-auth-utils',
  ],
  
  // Hybrid rendering
  routeRules: {
    '/': { prerender: true },          // Static, pre-rendered at build time
    '/blog/**': { isr: 3600 },         // Stale-while-revalidate, 1-hour TTL
    '/app/**': { ssr: false },         // SPA (dashboard, requires auth)
    '/api/**': { cors: true },         // API routes with CORS
  },
  
  runtimeConfig: {
    // Server-only (not exposed to client)
    databaseUrl: '',
    stripeSecretKey: '',
    // Public (exposed to client via useRuntimeConfig)
    public: {
      siteUrl: 'https://example.com',
    },
  },
  
  typescript: {
    strict: true,
  },
});

Server Routes with Nitro

Create CRUD server routes for a blog posts API.
Use Drizzle ORM for database access.
// server/api/posts/index.get.ts — GET /api/posts
export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const page = parseInt(query.page as string) || 1;
  const limit = Math.min(parseInt(query.limit as string) || 20, 100);
  
  const db = useDatabase(); // From server/utils/db.ts
  
  const [posts, total] = await Promise.all([
    db.select().from(schema.posts)
      .where(eq(schema.posts.published, true))
      .orderBy(desc(schema.posts.createdAt))
      .limit(limit)
      .offset((page - 1) * limit),
    db.select({ count: count() }).from(schema.posts)
      .where(eq(schema.posts.published, true)),
  ]);
  
  return {
    posts,
    pagination: {
      page,
      limit,
      total: total[0].count,
      totalPages: Math.ceil(total[0].count / limit),
    },
  };
});
// server/api/posts/index.post.ts — POST /api/posts
export default defineEventHandler(async (event) => {
  // Auth check
  const session = await getUserSession(event);
  if (!session?.user) {
    throw createError({ statusCode: 401, message: 'Unauthorized' });
  }
  
  const body = await readValidatedBody(event, (data) =>
    z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1),
      published: z.boolean().default(false),
    }).parse(data)
  );
  
  const db = useDatabase();
  const [post] = await db.insert(schema.posts).values({
    ...body,
    authorId: session.user.id,
    slug: slugify(body.title),
  }).returning();
  
  setResponseStatus(event, 201);
  return { post };
});
// server/api/posts/[id].ts — GET/PUT/DELETE /api/posts/:id
export default defineEventHandler(async (event) => {
  const id = parseInt(getRouterParam(event, 'id') ?? '');
  if (!id) throw createError({ statusCode: 400, message: 'Invalid ID' });
  
  const db = useDatabase();
  
  if (event.method === 'GET') {
    const [post] = await db.select().from(schema.posts).where(eq(schema.posts.id, id));
    if (!post) throw createError({ statusCode: 404, message: 'Not found' });
    return { post };
  }
  
  // Mutations require auth
  const session = await getUserSession(event);
  if (!session?.user) throw createError({ statusCode: 401, message: 'Unauthorized' });
  
  if (event.method === 'DELETE') {
    const [post] = await db.delete(schema.posts)
      .where(and(eq(schema.posts.id, id), eq(schema.posts.authorId, session.user.id)))
      .returning();
    if (!post) throw createError({ statusCode: 404, message: 'Not found or not authorized' });
    return { success: true };
  }
  
  if (event.method === 'PUT') {
    const body = await readValidatedBody(event, (data) =>
      z.object({
        title: z.string().optional(),
        content: z.string().optional(),
        published: z.boolean().optional(),
      }).parse(data)
    );
    
    const [updated] = await db.update(schema.posts)
      .set({ ...body, updatedAt: new Date() })
      .where(and(eq(schema.posts.id, id), eq(schema.posts.authorId, session.user.id)))
      .returning();
    
    if (!updated) throw createError({ statusCode: 404, message: 'Not found or not authorized' });
    return { post: updated };
  }
});

Data Fetching in Pages

Create a blog listing page and post detail page.
Handle loading states, errors, and SEO meta tags.
<!-- pages/blog/index.vue -->
<script setup lang="ts">
const route = useRoute();
const page = computed(() => parseInt(route.query.page as string) || 1);

const { data, pending, error, refresh } = await useFetch('/api/posts', {
  query: { page },   // Reactive — refetches when page changes
  watch: [page],
});

// SEO
useSeoMeta({
  title: 'Blog | MyApp',
  description: 'Latest posts from our team',
  ogTitle: 'Blog | MyApp',
});
</script>

<template>
  <div>
    <h1>Blog</h1>
    
    <div v-if="pending" class="loading-grid">
      <PostSkeleton v-for="i in 6" :key="i" />
    </div>
    
    <div v-else-if="error" class="error">
      <p>Failed to load posts.</p>
      <button @click="refresh">Retry</button>
    </div>
    
    <div v-else>
      <div class="posts-grid">
        <PostCard
          v-for="post in data?.posts"
          :key="post.id"
          :post="post"
        />
      </div>
      
      <Pagination
        :current-page="page"
        :total-pages="data?.pagination.totalPages ?? 1"
        @change="navigateTo({ query: { page: $event } })"
      />
    </div>
  </div>
</template>
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();

// useAsyncData gives more control than useFetch — custom key, transform
const { data: post, error } = await useAsyncData(
  `post-${route.params.slug}`,  // Cache key
  () => $fetch(`/api/posts/${route.params.slug}`).then(r => r.post),
);

// 404 on server side
if (!post.value) {
  throw createError({ statusCode: 404, message: 'Post not found' });
}

// Full SEO
useSeoMeta({
  title: post.value.title,
  description: post.value.excerpt,
  ogTitle: post.value.title,
  ogDescription: post.value.excerpt,
  ogImage: post.value.coverImage ?? '/og-default.png',
  articleAuthor: post.value.author.name,
  articlePublishedTime: post.value.createdAt,
});
</script>

<template>
  <article>
    <h1>{{ post?.title }}</h1>
    <time :datetime="post?.createdAt">{{ formatDate(post?.createdAt) }}</time>
    <!-- Rendered markdown content -->
    <div class="prose" v-html="post?.contentHtml" />
  </article>
</template>

Pinia State Management

We need a shopping cart that persists across page navigation
and syncs with the server when the user is logged in.
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([]);
  const isLoading = ref(false);
  
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  const itemCount = computed(() => items.value.reduce((n, item) => n + item.quantity, 0));
  
  async function addItem(productId: string, quantity = 1) {
    // Optimistic update
    const existing = items.value.find(i => i.productId === productId);
    if (existing) {
      existing.quantity += quantity;
    } else {
      const product = await $fetch(`/api/products/${productId}`);
      items.value.push({ productId, ...product, quantity });
    }
    
    // Persist if logged in
    const { loggedIn } = useUserSession();
    if (loggedIn.value) {
      await $fetch('/api/cart', {
        method: 'POST',
        body: { productId, quantity },
      });
    }
  }
  
  async function syncFromServer() {
    const { loggedIn } = useUserSession();
    if (!loggedIn.value) return;
    
    isLoading.value = true;
    try {
      const { cartItems } = await $fetch('/api/cart');
      items.value = cartItems;
    } finally {
      isLoading.value = false;
    }
  }
  
  return { items, isLoading, total, itemCount, addItem, syncFromServer };
}, {
  persist: {
    // @pinia/nuxt persists to localStorage
    storage: piniaPluginPersistedstate.localStorage(),
    paths: ['items'],  // Only persist items, not loading state
  },
});

Route Middleware

Protect all /app/* routes — redirect to /login if not authenticated.
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { loggedIn } = useUserSession();
  
  if (!loggedIn.value) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath },  // Return after login
    });
  }
});
<!-- pages/app/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
  ssr: false,  // SPA — dashboard doesn't need SSR
});
</script>

For connecting a Nuxt 3 frontend to a database layer, the Prisma guide covers Drizzle alternatives and migration patterns. For Vue 3 component patterns and the Composition API, the Vue guide covers composables and <script setup>. The Claude Skills 360 bundle includes Nuxt skill sets for full-stack Vue development. Start with the free tier to try server route generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free