21 Nuxt Tips You Need to Know

I spent weeks scouring the Nuxt docs to uncover hidden gems — features you didn’t know it had, and ones that are simply easy to forget about.

Then, I wrote an entire 195-page book based on this research, filling it with my 117 favourite tips:

LS Product Images.png

In this article, I’m sharing 21 of those tips with you!

Here’s the full list we’ll be covering:

  1. Disable auto-imports
  2. Environment Config Overrides
  3. Deduping Fetches
  4. Flatten Nuxt Content Routes
  5. Built-in Storage with Unstorage
  6. Query Params in Server Routes
  7. Where should config values go?
  8. Create Your Own Keyed Composable
  9. .nuxtignore
  10. Private Components in Layers
  11. NuxtLink Basics
  12. Inline Route Validation
  13. Scroll to the top on page load
  14. Official vs. Community Modules
  15. SSR Payload
  16. Layout Fallbacks
  17. Global Errors vs. Client-side Errors
  18. Custom Error Pages
  19. Mock Any Import for Testing
  20. Using useState for Server Rendered Data
  21. Paired Server Components

If you want more tips, check out my book, Nuxt Tips Collection. It’s actually much more than just a book:

  • A beautifully designed ebook with 117 concise and insightful tips on using Nuxt better
  • 58 days of emails — tips sent straight to your inbox for daily inspiration
  • 7 code repos — so you can dive into the code yourself and learn layers, server components and more

But enough of that, let’s get to the tips!

1. Disable auto-imports

You can completely disable auto-imports by using the new imports.scan option:

export default defineNuxtConfig({
imports: {
scan: false,
},
});

This will ignore any directories listed in the imports.dirs option, as well as ignoring auto-imports for the ~/composables and ~/utils directories.

To also prevent auto-importing components, you’ll need to configure the components option as well:

export default defineNuxtConfig({
imports: {
scan: false,
},
// Set to an empty array
components: [],
});

2. Environment Config Overrides

We often need different config based on the environment our app is running in, whether we’re running tests, in dev mode, or running in prod.

But instead of doing this:

export default defineNuxtConfig({
modules: process.env.NODE_ENV === 'development'
? ["@nuxtjs/html-validator"],
: []
});

We have a better, more type-safe way of doing it:

export default defineNuxtConfig({
$development: {
modules: ["@nuxtjs/html-validator"]
}
});

We can use the $test, $development and $production keys for environment-specific configs.

3. Deduping Fetches

Since 3.9 we can control how Nuxt deduplicates fetches with the dedupe parameter:

useFetch('/api/search/', {
query: {
search,
},
dedupe: 'cancel' // Cancel the previous request and make a new request
});

The useFetch composable (and useAsyncData composable) will re-fetch data reactively as their parameters are updated. By default, they’ll cancel the previous request and initiate a new one with the new parameters.

However, you can change this behaviour to instead defer to the existing request — while there is a pending request, no new requests will be made:

useFetch('/api/search/', {
query: {
search,
},
dedupe: 'defer' // Keep the pending request and don't initiate a new one
});

This gives us greater control over how our data is loaded and requests are made.

4. Flatten Nuxt Content Routes

I wanted to organize my blog content into several folders:

  • Articles: content/articles/
  • Newsletters: content/newsletters/

By default though, Nuxt Content would set up these routes to include those prefixes. But I want all of my routes to be at the root level:

We can do this manually for each Markdown file by overriding the _path property through it's frontmatter:

---
title: My Latest Article
date: today
_path: "/my-latest-article"
---

This is extremely tedious, error-prone, and generally annoying.

Luckily, we can write a simple Nitro plugin that will do this transform automatically.

Create a content.ts file in server/plugins/:

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:file:afterParse', (file) => {
for (const prefix of ['/articles', '/newsletters']) {
if (file._path.startsWith(prefix)) {
// Keep the prefix so we can query based on it still
file._original_dir = prefix;
// Remove prefix from path
file._path = file._path.replace(prefix, '');
}
}
});
});

Nitro is the server that Nuxt uses internally. We can hook into it's processing pipeline and do a bit of tweaking.

However, doing this breaks queryContent calls if we're filtering based on the path, since queryContent is looking at the _path property we've just modified. This is why we want to keep that original directory around.

We can modify our queryContent calls to filter on this new _original_dir property:

// Before
queryContent('/articles')
// After
queryContent()
.where({
_original_dir: { $eq: '/articles' },
});

Pro tip: use nuxi clean to force Nuxt Content to re-fetch and re-transform all of your content.

5. Built-in Storage with Unstorage

Nitro, the server that Nuxt uses, comes with a very powerful key-value storage system:

const storage = useStorage();
// Save a value
await storage.setItem('some:key', value);
// Retrieve a value
const item = await storage.getItem('some:key');

It’s not a replacement for a robust database, but it’s perfect for temporary data or a caching layer.

One great application of this “session storage” is using it during an OAuth flow.

In the first step of the flow, we receive a state and a codeVerifier. In the second step, we receive a code along with the state again, which let’s us use the codeVerifier to verify that the code is authentic.

We need to store the codeVerifier in between these steps, but only for a few minutes — perfect for Nitro’s storage!

The first step in the /oauth endpoint we store the codeVerifier:

// ~/server/api/oauth
// ...
const storage = useStorage();
const key = `verifier:${state}`;
await storage.setItem(key, codeVerifier);
// ...

Then we retrieve it during the second step in the /callback endpoint:

// ~/server/api/callback
// ...
const storage = useStorage();
const key = `verifier:${state}`;
const codeVerifier = await storage.getItem(key);
// ...

A simple and easy solution, with no need to add a new table to our database and deal with an extra migration.

This just scratches the surface. Learn more about the unstorage package that powers this: https://github.com/unjs/unstorage

6. Query Params in Server Routes

Getting values out of the query parameter in our server routes is straightforward:

import { getQuery } from 'h3';
export default defineEventHandler((event) => {
const params = getQuery(event);
});

If we have the query ?hello=world&flavours[]=chocolate&flavours[]=vanilla we’ll get back the following params object:

{
hello: 'world',
flavours: [
'chocolate',
'vanilla',
},
}

We can also use a validator function with getValidatedQuery:

import { getValidatedQuery } from 'h3';
export default defineEventHandler((event) => {
const params = getValidatedQuery(
event,
obj => Array.isArray(obj.flavours)
);
});

7. Where should config values go?

I think about it this way:

  • runtimeConfig: Use runtimeConfig for private or public tokens that need to be specified after the build using environment variables
  • app.config: Use app.config for public tokens that are determined at build time, such as website configuration (theme variant, title) or any project config that are not sensitive

Both runtimeConfig and app.config allow you to expose variables to your application. However, there are some key differences:

  1. runtimeConfig supports environment variables, whereas app.config does not. This makes runtimeConfig more suitable for values that need to be specified after the build using environment variables.
  2. runtimeConfig values are hydrated on the client side during run-time, while app.config values are bundled during the build process.
  3. app.config supports Hot Module Replacement (HMR), which means you can update the configuration without a full page reload during development.
  4. app.config values can be fully typed with TypeScript, whereas runtimeConfig cannot.

8. Create Your Own Keyed Composable

Use the same key generation magic that other built-in composables are using by adding your composable to the config under the optimization.keyedComposables property:

[
// Add in your own composable!
{
"name": "useMyCustomComposable",
"argumentLength": 2,
},
// Default composables
{
"name": "useId",
"argumentLength": 1
},
{
"name": "callOnce",
"argumentLength": 2
},
{
"name": "defineNuxtComponent",
"argumentLength": 2
},
{
"name": "useState",
"argumentLength": 2
},
{
"name": "useFetch",
"argumentLength": 3
},
{
"name": "useAsyncData",
"argumentLength": 3
},
{
"name": "useLazyAsyncData",
"argumentLength": 3
},
{
"name": "useLazyFetch",
"argumentLength": 3
}
]

Then, you’ll need to do something with that key property:

/**
* This is a naive implementation of useId just to illustrate.
*/
export default (key: string) => {
let id = key;
if (id.startsWith('$')) {
// Remove the $ from the key
id = id.slice(1);
// Make sure it starts with a letter and not a number
id = 'a' + id;
}
return id;
};

You can then rely on the auto-injected key:

<template>
<h2 :id="testId">useId</h2>
</template>
<script setup>
const testId = useId();
</script>

However, just like useAsyncData and useFetch, we can pass in our own key if needed. Nuxt will know not to inject one if we pass in a number of arguments that equals argumentLength.

9. .nuxtignore

Using the same syntax as your .gitignore file, you can configure Nuxt to ignore files and directories during build time:

# Ignore specific files
**/*ignore-me.vue
# Ignore a whole directory
components/ignore/

10. Private Components in Layers

What if we want a layer that has pages and layouts, but we want the components to be private and not available to the main app?

We can do that by giving that layer a custom configuration:

// privateLayer/nuxt.config.ts
export default defineNuxtConfig({
components: [],
});

By removing all auto-imports from components, we can make these components “private”. The only way to use them now is to manually import them based on their filepath.

The NuxtLink component is a workhorse, giving us access to the benefits of Universal Rendering without any extra effort.

It will automatically do client-side navigation and prefetch resources — keeping your site super fast!

It’s a drop-in replacement for any anchor tags:

<!-- Using an anchor tag -->
<a href="/articles">Articles</a>
<!-- Replace with NuxtLink -->
<NuxtLink to="/articles">Articles</NuxtLink>

Okay, not quite a straight drop-in, but pretty close.

It also works with external links, automatically adding in noopener and noreferrer attributes for security:

<!-- Using an anchor tag -->
<a href="www.masteringnuxt.com" rel="noopener noreferrer">
Mastering Nuxt
</a>
<!-- Replace with NuxtLink -->
<NuxtLink to="www.masteringnuxt.com">
Mastering Nuxt
</NuxtLink>

In some cases NuxtLink may not detect that the link is an external one, so you can tell it explicitly using the external prop:

<NuxtLink
to="www.masteringnuxt.com"
external
>
Mastering Nuxt
</NuxtLink>

This often happens when a redirect goes to an external URL, since NuxtLink has no knowledge of where the redirect is going.

This component uses the RouterLink component from Vue Router internally, so there are lots of other props you can use to customize behaviour.

12. Inline Route Validation

Use validate to validate inline whether or not we can actually go to a certain page.

We don’t need route middleware to validate a route. Instead, we can do this inline using definePageMeta:

definePageMeta({
validate(to) {
if (to.params.id === undefined) {
// Try to match on another route
return false;
}
// Success!
return true;
},
});

This is useful because we might have multiple pages that match a route, and we can use this to check.

We can also return an error if we know that something is wrong:

definePageMeta({
validate({ params }) {
const course = useCourse();
const chapter = course.chapters.find(
(chapter) => chapter.slug === params.chapterSlug
);
if (!chapter) {
return createError({
statusCode: 404,
message: 'Chapter not found',
});
}
return true;
},
});

This example is from Mastering Nuxt, where we check if the chapter exists before trying to render the page for that chapter.

However, validate is technically syntactic sugar for inline middleware. This means we can’t define both a validate function and define middleware in definePageMeta. We can refactor our validate function to be an inline middleware with a bit of work:

definePageMeta({
middleware: [
function (to) {
const course = useCourse();
const chapter = course.chapters.find(
(chapter) => chapter.slug === to.params.chapterSlug
);
if (!chapter) {
return abortNavigation(
createError({
statusCode: 404,
message: 'Chapter not found',
})
);
}
}
]
});

13. Scroll to the top on page load

You can make sure that Nuxt will scroll to the top of your page on a route change with the scrollToTop property:

definePageMeta({
scrollToTop: true,
});

If you want more control, you can also use a middleware-style function:

definePageMeta({
scrollToTop: (to, from) => {
// If we came from another docs page, make sure we scroll
if (to.path.includes('docs')) {
return true;
}
return false;
},
});

If you want even more control over the scroll behaviour, you can customize Vue Router directly using the ~/app/router.options.ts file:

import type { RouterConfig } from '@nuxt/schema';
export default <RouterConfig> {
scrollBehavior(to, from, savedPosition) {
// Simulate scroll to anchor behaviour
if (to.hash) {
return {
el: to.hash,
};
}
},
};

14. Official vs. Community Modules

There are lots of modules for Nuxt, and there is a useful naming convention that helps to understand what kind of module you’re dealing with:

  • Official modules — these are always prefixed with @nuxt/ and are actively maintained by the Nuxt team. For example, @nuxt/image, @nuxt/content, and @nuxt/fonts.
  • Community modules — these modules are maintained by the community but have been “curated” by the Nuxt team. They are prefixed with @nuxtjs/. For example, @nuxtjs/tailwindcss, @nuxtjs/color-mode, and @nuxtjs/supabase.
  • Third party modules — these are usually prefixed with nuxt- to indicate they are Nuxt-specific packages, and can be made by anyone. For example, nuxt-layers-utils and nuxt-content-assets.

You can find a searchable list of official and community modules on the Nuxt website.

If you’ve created a module and want it included, you can open a PR on the Nuxt Modules Github repo. You can also find all the other community modules there if you want to help with maintaining one of them!

15. SSR Payload

We can access the entire payload sent from the server to the client through useNuxtApp:

const nuxtApp = useNuxtApp();
const payload = nuxtApp.payload;

All of the data that’s fetched from our data-fetching composables is stored in the data key:

const { data: pictures } = await useFetch('pictures', '/api/pictures');
const nuxtApp = useNuxtApp();
console.log(nuxtApp.payload.data.pictures) // <- our data is here

The key that is used is based on the key param passed in to the composable. By default, it will auto-inject the key based on the filename and line number, but you can pass in a custom value if needed.

Similarly, all the data that’s stored for useState is in the state key, and can be accessed in the same way.

We also have a serverRendered boolean that let’s us know if the response was server rendered or not.

16. Layout Fallbacks

If you’re dealing with a complex web app, you may want to change what the default layout is:

<NuxtLayout fallback="differentDefault">
<NuxtPage />
</NuxtLayout>

Normally, the NuxtLayout component will use the default layout if no other layout is specified — either through definePageMeta, setPageLayout, or directly on the NuxtLayout component itself.

This fallback is great for large apps where you can provide a different default layout for each part of your app.

17. Global Errors vs. Client-side Errors

In Nuxt we have two types of errors:

  • Global errors: these errors can be thought of as “server-side” errors, but they’re still accessible from the client
  • Client-side errors: these errors only exist on the client, so they don’t affect the rest of your app and won’t show up in logs unless you use a logging service like LogRocket (there are many of these services out there, I don’t endorse any specific one).

It’s important to understand this distinction because these errors happen under different circumstances and need to be handled differently.

Global errors can happen any time the server is executing code. Mainly, this is during an API call, during a server-side render, or any of the code that glues these two together.

Client-side errors mainly happen while interacting within an app, but they can also happen on route changes because of how Nuxt’s Universal Rendering works.

It’s important to note that the NuxtErrorBoundary component only deals with client-side errors, and does nothing to handle or clear these global errors.

To do that, we need to use the error handling composables and utilities from Nuxt:

  • useError
  • clearError
  • createError
  • showError

18. Custom Error Pages

To create an error page, we need to add an error.vue file to the root of our application, alongside our app.vue and nuxt.config.ts files.

This is important, because this server page is not a “page” that is seen by the router. Not all Nuxt apps use file-based routing or use Vue Router, so we can’t rely on Vue Router to display the error page for us.

A super basic error page might look something like this:

<template>
<NuxtLayout>
<div>
<h1>Dang</h1>
<p>It looks like something broke.</p>
<p>Sorry about that.</p>
</div>
</NuxtLayout>
</template>

Not super helpful, but we can make it better by using the useError composable to grab more details about the global error:

const error = useError();

Now, we can add a more descriptive message to our error page:

<template>
<NuxtLayout>
<div class="prose">
<h1>Dang</h1>
<p>
<strong>{{ error.message }}</strong>
</p>
<p>It looks like something broke.</p>
<p>Sorry about that.</p>
</div>
</NuxtLayout>
</template>

We can update our error page to include a check for this statusCode:

<template>
<NuxtLayout>
<div class="prose">
<template v-if="error.statusCode === 404">
<h1>404!</h1>
<p>Sorry, that page doesn't exist.</p>
</template>
<template v-else>
<h1>Dang</h1>
<p>
<strong>{{ error.message }}</strong>
</p>
<p>It looks like something broke.</p>
<p>Sorry about that.</p>
</template>
<p>
Go back to your
<a @click="handleError">
dashboard.
</a>
</p>
</div>
</NuxtLayout>
</template>

Using this strategy we’re able to render what we need based on the type of error and the message that the error contains.

19. Mock Any Import for Testing

One handy helper method in @nuxt/test-utils is mockNuxtImport.

It's a convenience method to make it easier to mock anything that Nuxt would normally auto-import:

import { mockNuxtImport } from '@nuxt/test-utils/runtime';
mockNuxtImport('useAsyncData', () => {
return () => {
return { data: 'Mocked data' };
};
});
// ...tests

20. Using useState for server rendered data

One use of useState is to manage state between the server-side and the client-side.

The first challenge is hydration.

By default, Vue doesn’t do any hydration. If we’re using ref or reactive to store our state, then during the server-side render they don’t get saved and passed along. When the client is loading all our logic must run again.

But useState allows us to correctly sync state so we can reuse the work that was already done on the server.

The second challenge is what’s known as cross-request state pollution.

If multiple people all visit your website at the same time, and those pages are all first rendered on the server, that means that the same code is rendering out pages for different people at the same time.

This creates the opportunity for the state from one request to accidentally get mixed up in another request.

But useState takes care of this for us by creating an entirely new state object for each request, in order to prevent this from happening. Pinia does this too, which is why it’s also a recommended state management solution.

21. Paired Server Components

You can write more complex components that have different logic on the server and the client by splitting them into paired server components. All you need to do is keep the same name, changing only the suffix to *.client.vue and *.server.vue.

For example, in our Counter.server.vue we set up everything we want to run during SSR:

<template>
<div>This is paired: {{ startingCount }}</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ startingCount: number }>(), {
startingCount: 0,
});
</script>

We grab our startingCount prop and render it to the page — no need to do anything else because we’re not interactive at this point.

Then, Nuxt will find Counter.client.vue and ship that to the client in order to hydrate and make the component interactive:

<template>
<div>This is paired: {{ count }}</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ startingCount: number }>(),
{
startingCount: 0,
}
);
const offset = ref(0);
const count = computed(
() => props.startingCount + offset.value
);
onMounted(() => {
setInterval(() => {
offset.value++;
}, 1000);
});
</script>

We’re careful to make sure we avoid hydration mismatches, and we bootstrap our interactivity.

A nice feature to improve separation of concerns where you need it!

Nuxt Tips Collection

LS Product Images.png

If you enjoyed these tips, check out my book, Nuxt Tips Collection. It’s actually more than just a book:

  • A beautifully designed ebook with 117 concise and insightful tips on using Nuxt better
  • 58 days of emails — tips sent straight to your inbox for daily inspiration
  • 7 code repos — so you can dive into the code yourself and learn layers, server components and more