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.