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:
In this article, I’m sharing 21 of those tips with you!
Here’s the full list we’ll be covering:
If you want more tips, check out my book, Nuxt Tips Collection. It’s actually much more than just a book:
But enough of that, let’s get to the tips!
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 arraycomponents: [],});
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.
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.
I wanted to organize my blog content into several folders:
content/articles/
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 Articledate: 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 stillfile._original_dir = prefix;// Remove prefix from pathfile._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:
// BeforequeryContent('/articles')// AfterqueryContent().where({_original_dir: { $eq: '/articles' },});
Pro tip: use nuxi clean
to force Nuxt Content to re-fetch and re-transform all of your content.
Nitro, the server that Nuxt uses, comes with a very powerful key-value storage system:
const storage = useStorage();// Save a valueawait storage.setItem('some:key', value);// Retrieve a valueconst 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
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));});
I think about it this way:
Both runtimeConfig
and app.config
allow you to expose variables to your application. However, there are some key differences:
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.runtimeConfig
values are hydrated on the client side during run-time, while app.config
values are bundled during the build process.app.config
supports Hot Module Replacement (HMR), which means you can update the configuration without a full page reload during development.app.config
values can be fully typed with TypeScript, whereas runtimeConfig
cannot.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 keyid = id.slice(1);// Make sure it starts with a letter and not a numberid = '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
.
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 directorycomponents/ignore/
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.tsexport 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:
<NuxtLinkto="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.
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 routereturn 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',}));}}]});
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 scrollif (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 behaviourif (to.hash) {return {el: to.hash,};}},};
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:
@nuxt/
and are actively maintained by the Nuxt team. For example, @nuxt/image
, @nuxt/content
, and @nuxt/fonts
.@nuxtjs/
. For example, @nuxtjs/tailwindcss
, @nuxtjs/color-mode
, and @nuxtjs/supabase
.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!
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.
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.
In Nuxt we have two types of errors:
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
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.
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
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.
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!
If you enjoyed these tips, check out my book, Nuxt Tips Collection. It’s actually more than just a book: