Nuxt basics — directory conventions and server APIs
Chapter 18 covered useFetch and $fetch without touching the foundation they run on. This chapter covers that foundation — the directory conventions and what file names decide.
Nuxt is a framework adding SSR, routing, and server APIs to Vue. Its distinguishing trait is that behaviour is decided by where you put files rather than by a configuration file, and a good deal of it will not work until you know the conventions.
What you'll learn in this chapter
- What
npm create nuxt@latestasks and what it creates - The directories under
app/, and the roles ofserver//public//shared/ - How file names decide URLs, and the precedence of dynamic segments and catch-alls
- Applying layouts, and
definePageMeta. What can go in this macro, and what takes down the whole app - Narrowing methods through
server/apifile names, and what comes back when nothing matches
Creating a project
npm create nuxt@latest my-app
You can specify the template, the package manager, whether to install dependencies, whether to init git, and extra modules. All of them can be passed on the command line (--template / --packageManager / --install / --gitInit / --modules). Run it in a non-interactive terminal and it demands --template and --gitInit, because those two alone have no defaults.
There are five templates as of create-nuxt 3.37.0.
| Template | Contents |
|---|---|
minimal | The smallest setup, just app.vue |
content | Nuxt Content built in, for content-driven sites |
ui | With Nuxt UI |
module | A scaffold for building a Nuxt module |
v5-nightly | The nightly build of Nuxt 5 |
minimal creates only this.
my-app/
├── app/
│ └── app.vue
├── public/
│ ├── favicon.ico
│ └── robots.txt
├── nuxt.config.ts
├── package.json
├── tsconfig.json
├── README.md
└── .gitignore
app/ is created, but all it holds is app.vue. You create pages/ and the rest yourself when you need them.
tsconfig.json holds no settings of its own and merely references the four generated in .nuxt/ (app / server / shared / node). Generation is done by nuxt prepare, wired into package.json's postinstall. Delete node_modules and the types break until you run npm install.
The directory rules
In Nuxt 4 the application code lives under app/. There are eight places it can go.
| Directory | Purpose |
|---|---|
pages/ | Pages whose URL comes from the file name |
components/ | Automatically imported components |
composables/ | Automatically imported composables |
layouts/ | Layouts wrapping pages |
middleware/ | Code run before navigation |
plugins/ | Code run when the app is created |
utils/ | Automatically imported functions |
assets/ | Assets processed by the build tool |
Directly inside go app.vue (the root component), error.vue (the error page), and app.config.ts (reactive configuration).
There are fixed places outside app/ too. The first four to learn are these; there are also modules/ (your own modules), layers/, content/, and test/.
| Location | Purpose |
|---|---|
server/ | Server-side code, holding api/ routes/ middleware/ plugins/ utils/ |
public/ | Files served as-is without processing (robots.txt and the like) |
shared/ | Code used from both the Vue side and the server side |
nuxt.config.ts | Configuration (at the root) |
In Nuxt 3, pages/ and components/ sat directly at the repository root. In Nuxt 4 they have moved under app/. Paths copied from older articles are not recognised.
Pages and routing
Put a file in app/pages/ and its name becomes the URL.
app/pages/
├── index.vue → /
├── about.vue → /about
├── posts/
│ └── [id].vue → /posts/1
└── [...slug].vue → anything that matched none of the above
The id in [id].vue is readable through route.params.id. [...slug].vue receives everything remaining, arriving in route.params.slug as an array. A more specific route wins, so /posts/1 is handled by posts/[id].vue rather than [...slug].vue.
The frame that displays a page goes in app.vue.
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<NuxtPage /> is replaced with the page matching the URL, and <NuxtLayout> wraps the outside in a layout.
A listing page comes out like this.
<script setup lang="ts">
interface Post {
id: number
title: string
}
const { data: posts, status, error, refresh } = await useFetch<Post[]>('/api/posts')
</script>
<template>
<div>
<p v-if="status === 'pending'">Loading</p>
<p v-else-if="status === 'error'">{{ error?.message }}</p>
<ul v-else>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="`/posts/${post.id}`">{{ post.title }}</NuxtLink>
</li>
</ul>
<button type="button" @click="refresh()">Refresh</button>
</div>
</template>
useFetch's status has four values: 'idle' / 'pending' / 'success' / 'error'. Server-side rendering waits for resolution, so the first HTML arrives with the result already in it. You read status for client-side navigation and refetching.
To avoid making client-side navigation wait, use useLazyFetch. It is the same as useFetch with lazy: true, and the signature is unchanged. It only takes effect on the client; the server still waits (the first HTML carries status: success and the data). The name is a reserved word the compiler transforms, so do not give your own function the same name.
Layouts
Put an app/layouts/default.vue in place and pages that specify no layout are wrapped in it. The contents go where <slot /> is.
<template>
<div>
<header>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
</nav>
</header>
<main>
<slot />
</main>
</div>
</template>
To apply a different layout, pass the name to definePageMeta on the page.
<script setup lang="ts">
definePageMeta({ layout: 'alt' })
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.id}`)
</script>
<template>
<article>
<h1>{{ post?.title }}</h1>
</article>
</template>
What can go in definePageMeta
definePageMeta is a compiler macro for pages in app/pages/ only. Besides layout you can pass middleware / key / keepalive / validate / name / path / alias / redirect / scrollToTop / the various transitions, plus custom keys of any name.
definePageMeta hoists its argument, together with the local declarations it refers to, to the module's top level. Macros do not uniformly do this — defineProps rejects local references at compile time. defineModel does in principle too, but as seen in chapter 10, inserting a spread slips past the check and gives a runtime ReferenceError.
What gets hoisted is variable declarations and function declarations only. Those two are safe to refer to. A class declaration, by contrast, is not carried along, so referring to one gives ReferenceError: ... is not defined and a 500 for the whole app (with no diagnostic code attached).
function pickLayout() {
return 'alt'
}
const chosen = pickLayout()
definePageMeta({ layout: chosen })
Things hoisting cannot resolve fail in one of two places.
One is anything demanding Nuxt's context. There is no such context at the hoist destination, so a composable like useRoute() throws NUXT_E1001.
// write this and the whole app stops working
const route = useRoute()
definePageMeta({ layout: route.query.l === 'alt' ? 'alt' : 'default' })
The build passes. It fails at runtime, and not just that page but the whole app returns 500. Adding a single page makes even the top page unopenable, which is a hard kind of breakage to trace.
The other is a variable containing an await. That one stops at build time.
[NUXT_B4002] An `await` expression is used in a variable referenced by `definePageMeta`, which runs synchronously.
╰▶ fix: Move the `await` outside of variables referenced in `definePageMeta`, or use a static value instead
The reason is not context but that definePageMeta runs synchronously. Exactly as the diagnostic says, a wait unrelated to Nuxt such as await Promise.resolve('alt') fails the same way. Trying to decide the layout from a useFetch result lands here.
To vary the layout by condition, write a fixed value in definePageMeta and switch with <NuxtLayout name="...">, or branch inside the layout.
Server APIs
Put a file in server/api/ and it becomes an endpoint. The return value of defineEventHandler comes back as JSON.
export default defineEventHandler(() => {
return [
{ id: 1, title: 'First post' },
{ id: 2, title: 'Next post' },
]
})
Suffixes such as .get / .post in the file name narrow the HTTP method. Without a suffix it answers every method. You can put a separate file per method on the same path.
export default defineEventHandler(async (event) => {
const body = await readBody<{ title?: string }>(event)
if (!body?.title) {
throw createError({ statusCode: 400, message: 'title is required' })
}
return { id: 3, title: body.title }
})
Dynamic segments take the same [id] shape as pages and are read with getRouterParam.
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'id is required' })
}
return { id: Number(id), title: `Post ${id}` }
})
What comes back when the method does not match
You do not get a 405. With no matching handler, the request falls through to ordinary page resolution. So what comes back depends on whether a catch-all page exists.
| Catch-all | POST /api/posts (when it is GET-only) |
|---|---|
| None | 404 |
[...slug].vue exists | That page's HTML comes back with a 200 (route.params.slug is api/posts) |
The latter is the awkward one. $fetch returns the HTML as a string and throws nothing. It is a 200, so no error handling kicks in and processing continues with HTML sitting in data (a plain fetch(...).json() would throw). If you place a catch-all page, either branch to reject anything under /api/, or suspect a mistaken method.
Summary
npm create nuxt@latest <name>takes the template, package manager, install, git init, and modules. Whatever you do not pass on the command line is filled in interactively, and the two without defaults are--templateand--gitInit. As of create-nuxt 3.37.0 the templates areminimal/content/ui/module/v5-nightlyminimalcreates onlyapp/app.vue/public//nuxt.config.ts/package.json/tsconfig.json/README.md/.gitignore.tsconfig.jsonmerely references the four in.nuxt/, so the types break unlesspostinstall'snuxt preparehas run- Application code goes under
app/(the eight ofpages/components/composables/layouts/middleware/plugins/utils/assets), withapp.vue/error.vue/app.config.tsdirectly inside. Outsideapp/, the main ones areserver//public//shared//nuxt.config.ts. Nuxt 3 put these at the root, so paths from older articles do not work - File names decide URLs.
[id].vueis a dynamic segment and[...slug].vuea catch-all, and the more specific route wins useFetch'sstatushas four values:'idle'/'pending'/'success'/'error'. Server-side rendering waits for resolution, so the first HTML has the result in it.useLazyFetchonly avoids waiting on the client; the server still waits (it is a reserved word, so do not name your own function the same)- Layouts go in
app/layouts/, with the contents landing where<slot />is. Switch withdefinePageMeta({ layout: '...' }) definePageMetahoists its argument, together with the local declarations it refers to, to the top level (this is not a general property of macros). Only variable and function declarations are hoisted; aclassdeclaration is not carried and gives aReferenceError- Things hoisting cannot resolve fail in different places. Composables demanding Nuxt's context throw
NUXT_E1001at runtime and 500 the whole app (the build passes). A variable containing anawaitstops the build withNUXT_B4002— because it runs synchronously, so anawaitunrelated to Nuxt fails the same way - Suffixes such as
.get/.postonserver/api/file names narrow the method. No suffix answers every method. Dynamic segments use[id]and are read withgetRouterParam - A mismatched method is not a 405. With no catch-all page it is a 404; with
[...slug].vuethat page's HTML comes back with a 200.$fetchreturns that HTML as a string and throws nothing, so it breaks silently
- Nuxt — Installation — prerequisites and creating a project
- Nuxt — Directory Structure — the list of
app/andserver/ - Nuxt —
definePageMeta— the types of keys you can pass - Nuxt — Server Directory — method suffixes and dynamic routes
What to read next
- Authentication and deployment — where the token lives, and production configuration