Nuxt 3: Vue’s Answer to Next.js
For .NET engineers who know: ASP.NET Core, Razor Pages, and the Next.js concepts from the previous article You’ll learn: How Nuxt 3 applies the same meta-framework ideas as Next.js to Vue 3, what makes Nuxt more opinionated and convention-driven (and why that is often an advantage), and how the same features compare between the two frameworks Time: 15-20 min read
The .NET Way (What You Already Know)
If you had to choose a single word to describe ASP.NET Core’s design philosophy, it would be “convention.” Drop a file in the right place with the right name, follow the expected structure, and the framework takes care of the wiring. You do not configure what you do not need to change. The framework has a preferred way to do most things, and that preferred way is well-documented, consistent, and supported.
This is different from a “library-first” philosophy, where you assemble individual pieces and wire them yourself. ASP.NET Core is a full framework — routing, DI, middleware, configuration, logging, authentication are all provided and integrated by default. You extend the framework. The framework does not step aside.
Nuxt 3 is built on this same philosophy. It calls itself “the intuitive Vue framework,” and what that means in practice is: Nuxt makes the decisions. File-based routing, auto-imported components and composables, server routes, state management, data fetching — all of these have a prescribed, enforced convention. You do not configure a router. You do not import Vue Router. You create files in the right directories and Nuxt handles the rest.
Next.js, by contrast, gives you more explicit control. You opt into conventions. You configure what you want. It is closer to a well-structured library than a full framework.
Understanding this distinction — Nuxt is more opinionated, Next.js is more explicit — is the key to understanding when to choose each, and how they differ in practice.
The Nuxt 3 Way
Project Setup
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
npm install
npm run dev # starts on http://localhost:3000
The initial structure:
my-nuxt-app/
app.vue → root component (like app/layout.tsx in Next.js)
nuxt.config.ts → framework configuration
pages/ → file-based routes (create this directory to enable routing)
components/ → auto-imported components
composables/ → auto-imported composables
server/
api/ → server API routes
middleware/ → server middleware
public/ → static assets (like wwwroot/)
assets/ → processed assets (images, fonts, global CSS)
File-Based Routing
Nuxt’s routing works identically to Next.js at the conceptual level, but with a different directory structure and slightly different file conventions.
pages/ Next.js app/
index.vue → / page.tsx → /
about.vue → /about about/page.tsx → /about
products/
index.vue → /products products/page.tsx
[id].vue → /products/5 products/[id]/page.tsx
[...slug].vue → catch-all [...slug]/page.tsx
Key difference: in Nuxt, a single .vue file handles a route (e.g., pages/about.vue handles /about). In Next.js, routes require a directory with a page.tsx inside it. Nuxt’s approach produces fewer files and directories for simple route structures.
<!-- pages/products/[id].vue -->
<script setup lang="ts">
// useRoute() is auto-imported — no import statement needed
const route = useRoute()
const productId = Number(route.params.id)
// useFetch is auto-imported — see Data Fetching section
const { data: product, error } = await useFetch<Product>(`/api/products/${productId}`)
</script>
<template>
<div v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</div>
<div v-else-if="error">Error loading product</div>
</template>
Notice: no imports. useRoute and useFetch are automatically available in every .vue file inside pages/. This is Nuxt’s auto-import system.
Auto-Imports: Nuxt’s Most Distinctive Feature
This is the feature that feels most foreign to both .NET engineers and Next.js developers. In Nuxt, you never write import statements for:
- Vue Composition API functions (
ref,computed,watch,onMounted, etc.) - Nuxt composables (
useFetch,useRoute,useRouter,useState, etc.) - Your own composables placed in the
composables/directory - Your own components placed in the
components/directory
They are all available everywhere, automatically:
<!-- pages/example.vue — zero import statements -->
<script setup lang="ts">
// All of these are auto-imported:
const count = ref(0) // from Vue
const route = useRoute() // from Nuxt
const { data } = await useFetch('/api/data') // from Nuxt
// Your own composable in composables/useCounter.ts — auto-imported
const { increment, decrement } = useCounter()
</script>
<template>
<!-- MyButton.vue in components/ — auto-imported as <MyButton /> -->
<MyButton @click="increment">Click me</MyButton>
</template>
The equivalent in Next.js requires explicit imports in every file:
// Next.js — explicit imports everywhere
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { MyButton } from '@/components/MyButton'
For .NET engineers: Auto-imports feel like C#’s global usings (global using System; in modern .NET), combined with the convention that any class in a specific namespace is available everywhere. It is a build-time feature — Nuxt generates a TypeScript declaration file so your editor’s autocomplete still works.
The tradeoff: auto-imports are convenient and eliminate boilerplate, but they obscure where things come from. When you see useFetch with no import, you need to know whether it is from Nuxt, from a third-party module, or from your own composables/ directory. Teams with strong conventions manage this well. Teams without them can end up with naming collisions or confusion.
Layouts
Nuxt has a dedicated layouts/ directory. The layouts/default.vue layout automatically wraps every page:
<!-- layouts/default.vue — wraps all pages by default -->
<template>
<div>
<AppHeader />
<main>
<slot /> <!-- equivalent to @RenderBody() in Razor / {children} in Next.js -->
</main>
<AppFooter />
</div>
</template>
Switching to a different layout in a specific page:
<!-- pages/account/settings.vue -->
<script setup lang="ts">
// definePageMeta is a Nuxt macro — like [Authorize] or @attribute [Authorize] in Blazor
definePageMeta({
layout: 'dashboard', // uses layouts/dashboard.vue instead of layouts/default.vue
middleware: 'auth', // runs middleware/auth.ts before this page
})
</script>
<template>
<h1>Account Settings</h1>
</template>
<!-- layouts/dashboard.vue — used only on pages that opt in -->
<template>
<div class="dashboard">
<DashboardSidebar />
<div class="content">
<slot />
</div>
</div>
</template>
This is more explicit than Next.js nested layouts (which are implicit based on directory structure) but less automatic. Each page declares which layout it uses rather than inheriting one from its directory hierarchy.
Data Fetching: useFetch and useAsyncData
Nuxt provides two composables for data fetching that handle SSR correctly — they run on the server during page generation and serialize their results to the client, avoiding a second fetch on hydration.
useFetch — the high-level shorthand. Use this for most cases:
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
}
// Runs on server during SSR, result hydrated to client — no double-fetch
const {
data: products, // Ref<Product[] | null>
pending, // Ref<boolean> — is the request in flight?
error, // Ref<Error | null>
refresh, // () => Promise<void> — manually re-fetch
} = await useFetch<Product[]>('/api/products', {
query: { category: 'electronics' }, // adds ?category=electronics
headers: { 'X-Custom-Header': 'val' },
// cache key — Nuxt deduplicates requests with the same key
key: 'products-electronics',
})
</script>
<template>
<div v-if="pending">Loading...</div>
<div v-else-if="error">{{ error.message }}</div>
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }} — ${{ product.price }}
</li>
</ul>
</template>
useAsyncData — the lower-level primitive. Use this when you need more control, or when fetching from something other than a URL (a database client, a CMS SDK, etc.):
<script setup lang="ts">
// useAsyncData wraps any async function
const { data: user } = await useAsyncData('current-user', async () => {
// This function runs on the server — can use server-only code
const response = await $fetch('/api/users/me')
return response
})
// With dependencies — re-fetches when `userId` changes
const userId = ref(1)
const { data: userProfile } = await useAsyncData(
() => `user-profile-${userId.value}`, // dynamic cache key
() => $fetch(`/api/users/${userId.value}`),
{ watch: [userId] } // re-fetch when userId changes
)
</script>
$fetch (note the $ prefix) is Nuxt’s enhanced fetch — on the server it calls the URL directly (bypassing HTTP overhead for same-app API routes), and on the client it makes a normal HTTP request. It is auto-imported.
Comparison with Next.js data fetching:
// Next.js — Server Component (no dedicated hook, just async/await)
export default async function ProductsPage() {
const products = await fetch('/api/products', {
next: { revalidate: 60 }
}).then(r => r.json())
return <ProductList products={products} />
}
<!-- Nuxt — any component (useFetch handles SSR automatically) -->
<script setup lang="ts">
const { data: products } = await useFetch<Product[]>('/api/products', {
getCachedData: (key, nuxtApp) =>
nuxtApp.payload.data[key] // use cached data if available
})
</script>
The key difference: Next.js data fetching happens in Server Components (you cannot use await in a Client Component at the top level). Nuxt’s useFetch works in any component and handles SSR/hydration automatically.
Server Routes: server/api/
Nuxt’s server routes live in the server/api/ directory. Each file exports a default handler using defineEventHandler. The pattern is close to Next.js’s route.ts, but the method routing is handled differently:
// server/api/products/index.get.ts
// The .get. in the filename restricts this to GET requests
// (Next.js uses named exports: export async function GET() {})
import { defineEventHandler, getQuery } from 'h3'
export default defineEventHandler(async (event) => {
const query = getQuery(event) // equivalent to request.nextUrl.searchParams
const category = query.category as string | undefined
const products = await getProductsFromDb(category)
return products // Nuxt serializes this to JSON automatically
})
// server/api/products/index.post.ts
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody(event) // equivalent to request.json()
if (!body.name || !body.price) {
throw createError({
statusCode: 400,
statusMessage: 'name and price are required'
})
}
const product = await createProduct(body)
setResponseStatus(event, 201)
return product
})
// server/api/products/[id].get.ts
import { defineEventHandler, getRouterParam } from 'h3'
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const product = await getProductById(id)
if (!product) {
throw createError({ statusCode: 404, statusMessage: 'Product not found' })
}
return product
})
Nuxt’s server layer uses h3 as its HTTP framework — a minimal, cross-runtime HTTP library. It is analogous to ASP.NET’s Kestrel layer. You rarely interact with it directly; Nuxt’s abstractions (defineEventHandler, getQuery, readBody, createError) cover the common cases.
File naming convention for HTTP methods:
| File name | HTTP method |
|---|---|
route.ts or index.ts | All methods |
route.get.ts | GET only |
route.post.ts | POST only |
route.put.ts | PUT only |
route.delete.ts | DELETE only |
State Management with Pinia
Nuxt recommends Pinia for shared state management — the equivalent of a Redux store (in React) or a singleton service in ASP.NET’s DI container. Pinia is more ergonomic than Vuex and works natively with TypeScript.
The @pinia/nuxt module auto-integrates Pinia with Nuxt’s SSR pipeline:
npm install pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
})
// stores/useCartStore.ts
import { defineStore } from 'pinia'
interface CartItem {
id: number
name: string
quantity: number
price: number
}
export const useCartStore = defineStore('cart', () => {
// State — equivalent to fields in a service class
const items = ref<CartItem[]>([])
// Computed — equivalent to read-only properties
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
// Actions — equivalent to service methods
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.id === item.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...item, quantity: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
function clearCart() {
items.value = []
}
return { items, total, itemCount, addItem, removeItem, clearCart }
})
Using the store in any component (auto-imported because it is in composables/ or stores/):
<script setup lang="ts">
// useCartStore is auto-imported if placed in composables/ or stores/
// Otherwise import it explicitly
const cart = useCartStore()
</script>
<template>
<div>
<p>{{ cart.itemCount }} items — Total: ${{ cart.total.toFixed(2) }}</p>
<button @click="cart.addItem({ id: 1, name: 'Widget', price: 9.99 })">
Add Widget
</button>
</div>
</template>
For .NET engineers: a Pinia store is conceptually identical to an IMyService singleton registered in the DI container. It holds state, exposes computed values, and provides methods to mutate state. The difference is that it is reactive — components that read from the store automatically re-render when the store’s state changes, with no explicit subscriptions.
Middleware
Nuxt has two types of middleware:
Route middleware — runs before navigating to a page. Declared in middleware/ directory, used in pages via definePageMeta. Equivalent to an MVC ActionFilter or Razor Page’s OnPageHandlerExecuting:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { loggedIn } = useAuth()
if (!loggedIn.value) {
// Redirect — like returning a RedirectToActionResult in MVC
return navigateTo('/login')
}
})
<!-- pages/dashboard/index.vue — opts in to the auth middleware -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
</script>
Server middleware — runs on every request before routing. Placed in server/middleware/. Equivalent to ASP.NET Core middleware in Program.cs:
// server/middleware/request-logger.ts
import { defineEventHandler, getRequestURL } from 'h3'
export default defineEventHandler((event) => {
const url = getRequestURL(event)
console.log(`${new Date().toISOString()} ${event.method} ${url.pathname}`)
// No return value — falls through to the next handler (like calling next() in ASP.NET)
})
The Nuxt Module Ecosystem
Nuxt’s module system is comparable to ASP.NET NuGet packages that extend the framework — not just add a library, but actually integrate with the framework’s pipeline. A Nuxt module can add routes, extend the build pipeline, register server middleware, and configure the auto-import system.
Common modules you will encounter:
| Module | Equivalent in ASP.NET world |
|---|---|
@nuxtjs/tailwindcss | Tailwind CSS integration |
@pinia/nuxt | Pinia SSR integration |
@nuxtjs/color-mode | Dark mode with SSR |
nuxt-auth-utils | Session management (like ASP.NET Identity cookie auth) |
@nuxt/image | Image optimization (like Azure CDN image processing) |
@nuxtjs/i18n | Localization (like ASP.NET Core’s IStringLocalizer) |
@nuxt/content | File-based CMS (Markdown/YAML content) |
Adding a module:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'@nuxt/image',
],
// Module-specific configuration
image: {
quality: 80,
formats: ['webp', 'jpeg'],
},
})
Nuxt vs. Next.js: Side-by-Side
The best way to see the differences is to implement the same feature in both frameworks.
Feature: Product listing page with server-side data fetching
// Next.js — app/products/page.tsx
// Must be a Server Component to fetch server-side
// Client interactivity requires a separate 'use client' component
import { ProductCard } from '@/components/ProductCard'
import type { Product } from '@/types'
async function getProducts(): Promise<Product[]> {
const res = await fetch(`${process.env.API_URL}/products`, {
next: { revalidate: 60 }
})
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<main>
<h1>Products</h1>
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
</main>
)
}
<!-- Nuxt — pages/products.vue -->
<!-- Works as SSR or SSG with no special component type needed -->
<script setup lang="ts">
import type { Product } from '~/types'
const { data: products, error } = await useFetch<Product[]>('/api/products', {
key: 'products-list'
})
</script>
<template>
<main>
<h1>Products</h1>
<div class="grid grid-cols-3 gap-4">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
</main>
</template>
Feature: Auth-protected page
// Next.js — middleware.ts (global) + Server Component check
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/dashboard')) {
const token = request.cookies.get('auth-token')
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return NextResponse.next()
}
// Nuxt — middleware/auth.ts (per-route middleware)
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useAuth()
if (!loggedIn.value) {
return navigateTo('/login')
}
})
<!-- Nuxt — pages/dashboard/index.vue — opt-in to the middleware -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
Feature: Global state
// Next.js — React Context (or Zustand/Jotai)
// providers/CartProvider.tsx
'use client'
import { createContext, useContext, useState } from 'react'
const CartContext = createContext<CartContextType | null>(null)
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([])
// ...
return <CartContext.Provider value={{ items, addItem, removeItem }}>{children}</CartContext.Provider>
}
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be inside CartProvider')
return ctx
}
// Must wrap the app in app/layout.tsx:
// <CartProvider>{children}</CartProvider>
// Nuxt — Pinia store (no Provider wrapper needed)
// stores/useCartStore.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
function addItem(item: CartItem) { items.value.push(item) }
return { items, addItem }
})
// Used anywhere without a Provider:
// const cart = useCartStore()
Summary comparison table
| Feature | Next.js | Nuxt 3 |
|---|---|---|
| Philosophy | Explicit — you configure what you need | Convention — framework decides, you extend |
| Routing | app/ directory with page.tsx per route | pages/ directory with route.vue per route |
| Data fetching | async/await in Server Components | useFetch / useAsyncData in any component |
| SSR/SSG/ISR | Configured per fetch call (cache, revalidate) | Configured via useFetch options and nuxt.config.ts |
| Server routes | app/api/route.ts (named method exports) | server/api/route.method.ts (file name = method) |
| API layer | H3 via Next.js wrapping | H3 directly (Nuxt is built on Nitro which uses H3) |
| Client-only code | 'use client' directive | Client-only components in components/client/ |
| Auto-imports | No — explicit imports required | Yes — Vue, Nuxt composables, your code, components |
| State management | React Context, Zustand, Jotai (your choice) | Pinia (officially recommended and integrated) |
| Route middleware | middleware.ts (global, Edge Runtime only) | middleware/name.ts (per-page opt-in or global) |
| Layouts | app/layout.tsx — nesting by directory | layouts/ directory — explicit opt-in per page |
| Module system | No dedicated module system | @nuxt/module-builder — deep framework integration |
| ASP.NET analogy | Closest to Razor Pages (explicit, structured) | Closest to ASP.NET Core with conventions baked in |
| Learning curve | Lower if you know React | Lower if you know Vue 3 |
| Flexibility | More — fewer enforced conventions | Less — more decisions made for you |
Key Differences
Nuxt Is Built on Nitro, Which Is Not Node.js-Specific
Next.js is tightly coupled to Node.js (with some Edge Runtime support via Vercel). Nuxt’s server layer is built on Nitro, a server toolkit that can compile and deploy to Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge Functions, and other runtimes. A Nuxt app can be deployed to virtually any hosting environment that supports any of these runtimes — with a single configuration change in nuxt.config.ts.
The server/ Directory Is a Full Nitro Server
Everything in server/api/, server/routes/, server/middleware/, and server/plugins/ runs on Nitro — a full server environment with access to the filesystem, database connections, and all Node.js APIs. Unlike Next.js middleware (which runs on the Edge Runtime and is restricted to Web APIs), Nuxt’s server middleware is unrestricted.
useState in Nuxt Is an SSR-Safe Shared Ref
Nuxt has its own useState composable, which is different from React’s useState. Nuxt’s useState creates a shared, SSR-safe ref — the same value is available across all components during a single request on the server, and it is hydrated into the client. Use it instead of ref when you need state shared between components that survives SSR hydration:
// This state is shared across components and survives server→client hydration
const theme = useState<'light' | 'dark'>('app-theme', () => 'light')
Do not confuse this with React’s useState, which is local component state.
Gotchas for .NET Engineers
Gotcha 1: Auto-imports are build-time, not runtime magic
Auto-imports feel like they might have runtime overhead or rely on global scope. They do not. Nuxt’s build tool (powered by unimport) statically analyzes your code and injects the correct import statements before compiling. The output is identical to code you would write with explicit imports. Your editor (with the Nuxt VS Code extension) gets full TypeScript completion because Nuxt generates a .nuxt/types/ directory with declarations for all auto-imported items.
If you see a TypeScript error saying useFetch is not defined, run npx nuxi prepare to regenerate the type declarations:
npx nuxi prepare # regenerates .nuxt/types — run this after adding modules
Gotcha 2: useFetch key collisions cause data to bleed between routes
Every useFetch call has a cache key. If two different useFetch calls on different pages use the same key (or if you do not provide a key and Nuxt generates a collision), the cached data from one call may be returned to the other. Always provide explicit, unique keys for any fetch that uses dynamic parameters:
// WRONG — collision if two product pages are SSR'd in the same Nuxt process
const { data } = await useFetch(`/api/products/${id}`)
// CORRECT — unique key includes the dynamic value
const { data } = await useFetch(`/api/products/${id}`, {
key: `product-${id}`
})
This is especially important in production with Nuxt’s payload extraction — incorrect keys will serve wrong data to the wrong users.
Gotcha 3: Server routes are not automatically type-safe with useFetch
Unlike tRPC or Nuxt’s experimental typed fetch, a plain useFetch('/api/products') call has no compile-time connection to the server/api/products/index.get.ts handler’s return type. You must manually annotate the generic type parameter. Consider using $fetch.native typed with your DTOs, or the useNuxtData pattern for type sharing:
// You are responsible for keeping these in sync
interface Product { id: number; name: string; price: number }
const { data } = await useFetch<Product[]>('/api/products')
For a fully type-safe API layer in a production Nuxt app, consider nuxt-typed-router and organizing shared types in a shared/ or types/ directory that both server/ and pages/ import.
Gotcha 4: The pages/ directory must exist for routing to be enabled
In a fresh Nuxt project, if you delete or never create pages/, Nuxt falls back to using app.vue as a single-page application with no routing. The router is not included in the bundle until you create a pages/ directory. This is a surprising failure mode: you add a page file in the wrong directory, wonder why it does not route, and eventually discover that pages/ was missing entirely:
# If routing is not working, verify this directory exists:
ls pages/
# And that nuxt.config.ts does not disable the pages feature:
# pages: false ← this disables routing if set
Gotcha 5: Server middleware in server/middleware/ runs on every request including API routes
Unlike Next.js middleware (which you opt routes into via the matcher config), Nuxt server middleware runs on every single request to the server — including your API routes in server/api/. If your server middleware does something expensive (a database lookup, an external API call), it will run on every API request too. Use getRequestURL(event) to gate your middleware to specific paths:
// server/middleware/auth-check.ts
export default defineEventHandler((event) => {
const url = getRequestURL(event)
// Only apply to non-public routes
if (url.pathname.startsWith('/api/public')) {
return // skip — equivalent to calling next() without doing anything
}
// Apply auth check to everything else
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
})
Gotcha 6: definePageMeta cannot use runtime values
definePageMeta is a compiler macro — it is evaluated at build time, not runtime. You cannot use reactive values or computed data inside it:
// WRONG — userRole is runtime state, definePageMeta is build-time
const userRole = ref('admin')
definePageMeta({
middleware: userRole.value === 'admin' ? 'admin-auth' : 'user-auth' // build error
})
// CORRECT — put the logic in the middleware itself
definePageMeta({ middleware: 'role-auth' })
// middleware/role-auth.ts
export default defineNuxtRouteMiddleware(() => {
const { role } = useAuth()
if (role.value !== 'admin') return navigateTo('/unauthorized')
})
Hands-On Exercise
Build a small Nuxt 3 product catalog that mirrors the Next.js exercise from the previous article. Implement the same feature set so you can compare the approaches.
Requirements:
pages/
index.vue → Home page: static, "Welcome to the store"
products/
index.vue → List all products (useFetch from server route)
[id].vue → Product detail page with a FavoriteButton component
server/
api/
products/
index.get.ts → Return hardcoded product list as JSON
[id].get.ts → Return single product or 404
middleware/
logger.ts → Log every request path with timestamp
components/
FavoriteButton.vue → Client-side component: toggle favorite state with ref()
ProductCard.vue → Server-rendered card: accepts product prop
layouts/
default.vue → Site header with nav links; <slot /> for content
middleware/
auth.ts → Check for a 'loggedIn' cookie; redirect to /login if absent
pages/
dashboard/
index.vue → Protected page using definePageMeta({ middleware: 'auth' })
stores/
useCartStore.ts → Pinia store with items ref, addItem action, total computed
Specific requirements:
- Full TypeScript — define a
Productinterface intypes/index.ts server/api/products/index.get.tsmust usedefineEventHandlerwith typed returnpages/products/[id].vuemust useuseFetchwith an explicitkeyFavoriteButton.vuemust be a client-only component (check Nuxt docs for<ClientOnly>wrapper or theclientcomponent suffix)- Demonstrate auto-imports — no import statements for Vue composables or
useFetch
Quick Reference
| Concept | Nuxt 3 | Next.js | ASP.NET Equivalent |
|---|---|---|---|
| Page file | pages/products.vue | app/products/page.tsx | Pages/Products/Index.cshtml |
| Dynamic route | pages/products/[id].vue | app/products/[id]/page.tsx | Pages/Products/{id}.cshtml |
| Default layout | layouts/default.vue | app/layout.tsx | _Layout.cshtml |
| Named layout | layouts/dashboard.vue + definePageMeta | Directory-based nesting | Nested layouts (manual) |
| Data fetching (SSR) | useFetch / useAsyncData | async Server Component | OnGetAsync in Razor Page |
| API endpoint (all methods) | server/api/route.ts | app/api/route.ts | [ApiController] |
| API endpoint (GET only) | server/api/route.get.ts | export async function GET() | [HttpGet] |
| Throw HTTP error | throw createError({ statusCode: 404 }) | return NextResponse.json({}, { status: 404 }) | return NotFound() |
| Route middleware | middleware/auth.ts + definePageMeta | middleware.ts (global) | ActionFilter / [Authorize] |
| Server middleware | server/middleware/name.ts | middleware.ts (Edge only) | Program.cs middleware |
| Global state | Pinia store in stores/ | React Context / Zustand | Singleton service (DI) |
| Auto-imported composables | Anything in composables/ | No auto-imports | (no equivalent) |
| Auto-imported components | Anything in components/ | No auto-imports | Razor tag helpers (partial) |
| Framework config | nuxt.config.ts | next.config.ts | Program.cs + appsettings.json |
| Add framework extension | Nuxt module in nuxt.config.ts | No module system | NuGet package + builder.Services.Add* |
| Client-only component | <ClientOnly> wrapper or .client.vue suffix | 'use client' directive | Blazor WebAssembly |
| SSR state hydration | useState('key', () => default) | Server Component props | ViewData / Model binding |
| Static assets | public/ | public/ | wwwroot/ |
| Processed assets | assets/ | src/assets/ (convention) | Build pipeline assets |
| Dev server | npm run dev | npm run dev | dotnet watch |
| Production build | npm run build | npm run build | dotnet publish |
Further Reading
- Nuxt 3 documentation — Start with “Getting Started” and the “Directory Structure” section. The docs are well-organized and the official source for auto-import behavior
- Pinia documentation — Covers stores, plugins, and SSR setup in detail
- Nitro server documentation — The underlying server framework for Nuxt. Relevant when you need advanced server configuration, deployment targets, or server plugins
- Nuxt module ecosystem — The official module registry. Search by category to find integrated solutions for auth, CMS, image optimization, analytics, etc.
- Nuxt vs. Next.js — community comparison — Nuxt team’s own comparison, which is worth reading alongside the Next.js team’s perspective
- unimport — how auto-imports work — The build-time module that powers Nuxt’s auto-import system, if you want to understand the mechanism