Updated June 2026. Tested on Nuxt 3.x. Part of the Techalyst Vue and Nuxt series.
Nuxt hands you a pile of conventions and composables, and the docs cover each one in isolation. What you do not get is the short list of small things that actually save time once you are building real apps. After a few projects you collect them. Here are the ones worth keeping, grouped by where they help rather than as a numbered pile.
Data fetching
useFetch and useAsyncData both cache by a key so the same request does not run twice during hydration. The detail people miss is what happens when a request with the same key fires again while one is still in flight. That is the dedupe option:
const { data } = await useFetch('/api/products', {
dedupe: 'defer', // keep the pending request, ignore the new one
})
The default is cancel, which throws away the in-flight request and starts again. defer keeps the first one and skips the duplicate. On something like a search box that refetches on every keystroke, picking the right one saves a lot of wasted requests.
When you just need to call an endpoint once, outside the caching dance, reach for $fetch directly. useFetch is for data the page renders. $fetch is for actions, like posting a form.
Where config values belong
Nuxt gives you two places to put configuration and they are not interchangeable.
runtimeConfig is for anything driven by the environment or anything secret. Keys are server only by default, and you expose what the browser needs under public:
// nuxt.config.ts
runtimeConfig: {
stripeSecret: '', // server only, filled from NUXT_STRIPE_SECRET
public: {
apiBase: '/api', // available on the client too
},
}
app.config is for non-sensitive values that are part of the build, like a theme name or a feature flag. It is reactive and bundled, so do not put secrets there. The rule of thumb: if it comes from an environment variable or must stay private, it is runtimeConfig. If it is a fixed part of the app, it is app.config.
You can also override config per environment right in nuxt.config, which beats scattering conditionals:
$production: { runtimeConfig: { public: { apiBase: 'https://api.example.com' } } },
$development: { runtimeConfig: { public: { apiBase: 'http://localhost:3333' } } },
Pages and routing
definePageMeta does more than set a layout. You can validate the route inline, which is perfect for catching a bad param before the page renders:
definePageMeta({
validate(route) {
return /^\d+$/.test(route.params.id) // only numeric ids
},
})
Return false and Nuxt shows a 404. You can also throw createError from here for a more specific failure.
Two smaller ones in the same family. NuxtLink has an external prop, so you can force a full page navigation for links that leave your app instead of letting the router try to handle them. And if a page should always open scrolled to the top, set it in the meta:
definePageMeta({ scrollToTop: true })
State that survives SSR
This one trips up everyone coming from a plain Vue app. On the server a module-level ref is shared across every request, so one user's data can leak into another's. The fix is useState, which gives you state that is keyed, serialised into the page, and isolated per request:
// composables/useUser.ts
export const useUser = () => useState('user', () => null)
Use useState for any shared state that is set during server rendering. Keep a plain ref only for state that lives entirely in one component on the client.
Errors you can control
To trigger Nuxt's error handling on purpose, throw createError. Mark it fatal to show the full error page rather than a caught client error:
if (!product) {
throw createError({ statusCode: 404, statusMessage: 'Product not found', fatal: true })
}
The page that renders is error.vue at the root of your project. It receives the error as a prop, and you call clearError({ redirect: '/' }) to recover from it. One file gives you a branded 404 and 500 for the whole app.
Server routes
Files under server/api are real backend endpoints. Reading query params is easy, and there is a validating version worth knowing:
// server/api/search.get.ts
export default defineEventHandler((event) => {
const { q } = getQuery(event) // raw
// or validate and coerce in one step:
const { page } = getValidatedQuery(event, schema)
return search(q, page)
})
Nuxt also ships a key-value store you do not have to set up. useStorage works on the server for caching, rate-limit counters, or anything you would otherwise reach for Redis for in a small app:
const storage = useStorage('cache')
await storage.setItem('products', list)
const cached = await storage.getItem('products')
Server and client only components
Sometimes a component should only ever run in one place. Name it with a suffix and Nuxt handles the rest. Comments.server.vue renders on the server and ships no JavaScript for it. Map.client.vue only mounts in the browser, which is how you wrap a library that touches window without it blowing up during SSR.
Wrapping up
None of these are hard, they are just the kind of thing you only learn by hitting the wall first. Pick the right dedupe for your fetches, keep secrets in runtimeConfig and fixed values in app.config, validate routes in definePageMeta, use useState for anything shared across SSR, throw createError for real error pages, and lean on server routes and useStorage before you reach for extra infrastructure. Keep this short list nearby and Nuxt starts feeling a lot less like guesswork.
All comments ()
No comments yet
Be the first to leave a comment on this post.