Skip to main content

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@latest asks and what it creates
  • The directories under app/, and the roles of server/ / 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/api file 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.

TemplateContents
minimalThe smallest setup, just app.vue
contentNuxt Content built in, for content-driven sites
uiWith Nuxt UI
moduleA scaffold for building a Nuxt module
v5-nightlyThe 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.

DirectoryPurpose
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/.

LocationPurpose
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.tsConfiguration (at the root)
How this differs from Nuxt 3 articles

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.

app/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.

app/pages/index.vue
<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.

app/layouts/default.vue
<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.

app/pages/posts/[id].vue
<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.

server/api/posts.get.ts
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.

server/api/posts.post.ts
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.

server/api/posts/[id].get.ts
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-allPOST /api/posts (when it is GET-only)
None404
[...slug].vue existsThat 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 --template and --gitInit. As of create-nuxt 3.37.0 the templates are minimal / content / ui / module / v5-nightly
  • minimal creates only app/app.vue / public/ / nuxt.config.ts / package.json / tsconfig.json / README.md / .gitignore. tsconfig.json merely references the four in .nuxt/, so the types break unless postinstall's nuxt prepare has run
  • Application code goes under app/ (the eight of pages / components / composables / layouts / middleware / plugins / utils / assets), with app.vue / error.vue / app.config.ts directly inside. Outside app/, the main ones are server/ / 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].vue is a dynamic segment and [...slug].vue a catch-all, and the more specific route wins
  • useFetch's status has four values: 'idle' / 'pending' / 'success' / 'error'. Server-side rendering waits for resolution, so the first HTML has the result in it. useLazyFetch only 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 with definePageMeta({ layout: '...' })
  • definePageMeta hoists 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; a class declaration is not carried and gives a ReferenceError
  • Things hoisting cannot resolve fail in different places. Composables demanding Nuxt's context throw NUXT_E1001 at runtime and 500 the whole app (the build passes). A variable containing an await stops the build with NUXT_B4002 — because it runs synchronously, so an await unrelated to Nuxt fails the same way
  • Suffixes such as .get / .post on server/api/ file names narrow the method. No suffix answers every method. Dynamic segments use [id] and are read with getRouterParam
  • A mismatched method is not a 405. With no catch-all page it is a 404; with [...slug].vue that page's HTML comes back with a 200. $fetch returns that HTML as a string and throws nothing, so it breaks silently
Related references