VueNuxtViteVueTips & Tricks
Curated collection of practical tips, tricks and best practices for building modern Vue applications.
Nuxt 3.14 introduces the `shared/` folder for types and utilities that work in both server and client contexts with auto-imports.
project/
shared/
utils/
format.ts # Auto-imported everywhere
types/
index.ts # Shared type definitions
server/
api/
users.get.ts # Can use shared utils
app.vue # Can also use shared utils
// shared/utils/format.ts
export function formatCurrency(amount: number, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency', currency
}).format(amount)
}
<!-- app.vue — auto-imported, no import needed -->
<template>
<p>{{ formatCurrency(99.99) }}</p>
</template>
// server/api/invoice.get.ts — same auto-import works server-side
export default defineEventHandler(() => {
return { total: formatCurrency(250) }
})
Nuxt 3.13 lets you control when `NuxtLink` prefetches — on hover/focus interaction, on viewport visibility, or both.
<template>
<!-- Default: prefetch when link is visible in viewport -->
<NuxtLink to="/about">About</NuxtLink>
<!-- Prefetch only on hover or focus (saves bandwidth) -->
<NuxtLink to="/heavy-page" prefetch-on="interaction">
Heavy Page
</NuxtLink>
<!-- Prefetch on both visibility AND interaction -->
<NuxtLink
to="/dashboard"
:prefetch-on="{ visibility: true, interaction: true }"
>
Dashboard
</NuxtLink>
</template>
// nuxt.config.ts — set a global default for all NuxtLinks
export default defineNuxtConfig({
experimental: {
defaults: {
nuxtLink: {
prefetch: true,
prefetchOn: { visibility: false, interaction: true },
},
},
},
})
Nuxt 3.13 supports route groups using parentheses in folder names — organize pages logically without changing the URL structure.
pages/
index.vue → /
(marketing)/
about.vue → /about
contact.vue → /contact
(shop)/
products.vue → /products
cart.vue → /cart
(auth)/
login.vue → /login
register.vue → /register
<!-- pages/(marketing)/about.vue -->
<template>
<div>
<!-- URL is /about, NOT /(marketing)/about -->
<h1>About Us</h1>
</div>
</template>
Nuxt 3.11's `usePreviewMode()` composable lets you toggle preview mode for draft content with a single call.
<!-- pages/[...slug].vue -->
<script setup>
const { enabled, state } = usePreviewMode()
</script>
<template>
<div>
<div v-if="enabled" class="preview-banner">
Preview mode is active
</div>
<ContentRenderer :value="page" />
</div>
</template>
// Activate by visiting: /my-page?preview=true&token=my-secret
// You can also customize the enable check:
const { enabled, state } = usePreviewMode({
shouldEnable: () => {
return route.query.preview === 'true'
&& route.query.token === 'my-secret'
},
getState: (currentState) => {
return { token: route.query.token, ...currentState }
},
})
Vue 3.5 supports built-in lazy hydration strategies for async components — hydrate only when visible, when idle, or on interaction.
<script setup>
import {
defineAsyncComponent,
hydrateOnVisible,
hydrateOnIdle,
hydrateOnInteraction,
} from 'vue'
// Hydrate when the component scrolls into view
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
hydrate: hydrateOnVisible(),
})
// Hydrate when the browser is idle
const AdBanner = defineAsyncComponent({
loader: () => import('./AdBanner.vue'),
hydrate: hydrateOnIdle(5000),
})
// Hydrate on specific user interactions
const Dropdown = defineAsyncComponent({
loader: () => import('./Dropdown.vue'),
hydrate: hydrateOnInteraction(['click', 'mouseover']),
})
</script>
<template>
<HeavyChart />
<AdBanner />
<Dropdown />
</template>
Vue 3.5's `<Teleport defer>` waits until the current render cycle is complete, so you can teleport to a target rendered later in the same template.
<!-- Before (Vue 3.4) — this FAILS because #container doesn't exist yet -->
<template>
<Teleport to="#container">
<p>Teleported content</p>
</Teleport>
<div id="container"></div>
</template>
<!-- After (Vue 3.5+) — defer waits for the full render cycle -->
<template>
<Teleport defer to="#container">
<p>Teleported content</p>
</Teleport>
<div id="container"></div>
</template>
Vue 3.5's `onWatcherCleanup()` lets you register cleanup callbacks inside watchers — perfect for aborting stale API requests.
<script setup>
import { ref, watch, onWatcherCleanup } from 'vue'
const userId = ref(1)
watch(userId, (newId) => {
const controller = new AbortController()
fetch(`/api/users/${newId}`, { signal: controller.signal })
.then(r => r.json())
.then(data => { /* handle data */ })
// If userId changes before fetch completes, abort the previous request
onWatcherCleanup(() => {
controller.abort()
})
})
</script>
<!-- Also works inside watchEffect -->
<script setup>
import { ref, watchEffect, onWatcherCleanup } from 'vue'
const searchQuery = ref('')
watchEffect(() => {
const controller = new AbortController()
fetch(`/api/search?q=${searchQuery.value}`, {
signal: controller.signal
})
.then(r => r.json())
.then(data => { /* update results */ })
onWatcherCleanup(() => controller.abort())
})
</script>
Vue 3.5's `useId()` generates unique IDs that are stable across server and client renders — perfect for form accessibility.
<script setup>
import { useId } from 'vue'
const id = useId()
</script>
<template>
<form>
<label :for="id">Email:</label>
<input :id="id" type="email" />
</form>
</template>
<!-- Each component instance gets its own unique ID -->
<script setup>
import { useId } from 'vue'
defineProps<{ label: string }>()
const id = useId()
</script>
<template>
<div>
<label :for="id">{{ label }}</label>
<select :id="id">
<slot />
</select>
</div>
</template>
Vue 3.5 introduces `useTemplateRef()` — a cleaner, type-safe way to access template refs without relying on matching variable names.
<!-- Before (Vue 3.4) -->
<script setup>
import { ref, onMounted } from 'vue'
// variable name MUST match the ref attribute
const inputEl = ref(null)
onMounted(() => {
inputEl.value.focus()
})
</script>
<template>
<input ref="inputEl" />
</template>
<!-- After (Vue 3.5+) -->
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const input = useTemplateRef('my-input')
onMounted(() => {
input.value.focus()
})
</script>
<template>
<input ref="my-input" />
</template>
In Vue 3.5, you can destructure props with reactive default values using native JavaScript syntax — no more `withDefaults()` needed.
<!-- Before (Vue 3.4) -->
<script setup lang="ts">
const props = withDefaults(
defineProps<{
count?: number
message?: string
}>(),
{
count: 0,
message: 'hello',
}
)
</script>
<!-- After (Vue 3.5+) -->
<script setup lang="ts">
const { count = 0, message = 'hello' } = defineProps<{
count?: number
message?: string
}>()
</script>
<template>
<p>{{ count }} - {{ message }}</p>
</template>
Starting in Vue 3.4, the recommended approach to achieve two-way data binding is using the `defineModel()` macro.
<!-- before defineModel -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
<!-- after defineModel -->
<script setup>
const model = defineModel();
</script>
<template>
<input v-model="model" />
</template>
One of the most common mistakes I see across Vue codebases is the misuse of `ref()`. You don't have to wrap every variable in `ref()`, only wrap it when you need reactivity. 💡
<script setup>
// this doesn't need to be wrapped in ref()
// no need for reactivity here
const links = [
{
name: 'about',
href: '/about'
},
{
name: 'terms of service',
href: '/tos'
},
{
name: 'contact us',
href: '/contact'
}
]
// isActive flag needs to be reactive to reflect UI changes
// that's why it's a good idea to wrap tabs into ref
const tabs = ref([
{
name: 'Privacy',
url: '/privacy',
isActive: true
},
{
name: 'Permissions',
url: '/permissions',
isActive: false
}
])
</script>
From Vue 3.4, you can make use of v-bind same name shorthand
<template>
<!-- You can now shorten this: -->
<img :id="id" :src="src" :alt="alt">
<!-- To this: -->
<img :id :src :alt>
</template>
In Vue, when you are using large data structures and you don't need deep reactivity, you can make use of shallowRef instead of ref.
const state = shallowRef({ count: 1 })
// does NOT trigger change
state.value.count = 2
// does trigger change
state.value = { count: 2 }
In Vue, you can type your component emits to have better error handling and editor support.
const emit = defineEmits<{
change: [id: number]
update: [value: string]
}>()
In Vue, you can use any dynamic value directly in your `<style>` thanks to the `v-bind` directive. It is fully reactive.
<style scoped>
button {
background-color: v-bind(backgroundColor);
}
</style>
Overusing this data fetching pattern in many components in Vue? Thanks to Tanstack Query for Vue, you can reduce the boilerplate and take advantage of some useful features like auto-caching, auto-refetching and much more. ✅ It's a fantastic and powerful library! 💪🏻
// overusing this data fetching pattern in many components?c
const posts = ref([]);
const isLoading = ref(false);
const isError = ref(false);
async function fetchPosts() {
isLoading.value = true;
isError.value = false;
try {
const response = await fetch('someurl');
posts.value = await response.json();
} catch(error) {
isError.value = true;
} finally {
isLoading.value = false;
}
}
onMounted(() => {
fetchPosts();
})
// you can replace it with a few lines of code thanks to Tanstack Query (Vue Query) ✅
const {data: posts, isLoading, isError} = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
async function fetchPosts() {
const response = await fetch('someurl');
const data = await response.json();
return data;
}
In Vue, when you're using scoped styles but want just one rule to apply globally, you can use the `:global` pseudo-class instead of creating another <style>.
<style scoped>
:global(.red) {
color: red;
}
</style>
In Nuxt, you can reduce CLS by using local font fallbacks thanks to the awesome Fontaine library.
install the package
npm install -D @nuxtjs/fontaine
and set the module inside nuxt.config
export default defineNuxtConfig({
modules: ['@nuxtjs/fontaine'],
})
And that's it!
You can set the default values for your props even when you are using `defineProps` with type-only declaration. This is possible thanks to the `withDefaults` macro.
<script setup lang="ts">
export interface Props {
variant?: 'primary' | 'secondary'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
disabled: false
})
</script>
In Nuxt Content, you can search your content thanks to the experimental feature called ContentSearch.
first you have to enable this option in your nuxt.config.ts
export default defineNuxtConfig({
content: {
experimental: {
search: true
}
}
})
and then use it in your components
<script lang="ts" setup>
const search = ref('')
const results = searchContent(search)
</script>
<template>
<main>
<input v-model="search">
<pre>{{ results }} </pre>
</main>
</template>
In Nuxt Content, you can easily generate links to the previous and next articles.
<script setup lang="ts">
const route = useRoute();
const [prev, next] = await queryContent()
.only(['_path', 'title'])
.sort({ date: -1 })
.findSurround(route.path);
</script>
<template>
<NuxtLink v-if="prev" :to="prev._path">
<span>previous</span>
</NuxtLink>
</template>
In Vue, you can easily register a custom directive by creating an object containing lifecycle hooks with the 'v-' prefix.
<script setup>
// enables v-focus in templates
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
In Vite, you can create custom import aliases. This makes creating absolute import paths much easier.
// vite.config.ts
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: [
{
find: '@',
replacement: fileURLToPath(new URL('./src', import.meta.url))
},
]
}
})
// tsconfig.ts
{
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
],
}
}
}
// Thanks to the absolute path import aliases,
// the import statement looks the same for every component.
import Button from '@/components/Button.vue'
import Dropdown from '@/components/Dropdown.vue'
// By using relative imports, the import statements can vary between files
import Button from './Button.vue'
import Button from './../Button.vue'
import Dropdown from './components/Dropdown.vue'
In Nuxt Content, it is really easy to create a sitemap of all your pages.
// api/routes/sitemap.ts
import { SitemapStream, streamToPromise } from 'sitemap';
import { serverQueryContent } from '#content/server';
export default defineEventHandler(async (event) => {
const docs = await serverQueryContent(event).find();
const staticSites = [
{
_path: '/'
},
{
_path: '/about'
},
{
_path: '/open-source'
}
];
const sitemapElements = [...staticSites, ...docs];
const sitemap = new SitemapStream({
hostname: import.meta.env.VITE_BASE_URL as string
});
for (const doc of sitemapElements) {
sitemap.write({
url: doc._path,
changefreq: 'monthly'
});
}
sitemap.end();
return streamToPromise(sitemap);
});
In Nuxt Content, it is really easy to create an RSS feed of all your content.
// api/routes/rss.ts
import RSS from 'rss';
import { serverQueryContent } from '#content/server';
export default defineEventHandler(async (event) => {
const BASE_URL = 'https://your-domain.com';
const feed = new RSS({
title: 'Your title',
site_url: BASE_URL,
feed_url: `${BASE_URL}/rss.xml`
});
const docs = await serverQueryContent(event)
.sort({ date: -1 })
.where({ _partial: false })
.find();
for (const doc of docs) {
feed.item({
title: doc.title ?? '-',
url: `${BASE_URL}${doc._path}`,
date: doc.date,
description: doc.description
});
}
const feedString = feed.xml({ indent: true });
setHeader(event, 'content-type', 'text/xml');
return feedString;
});
If you want a CSS selector in scoped styles to be "deep", i.e. affecting child components, you can use the :deep() pseudo-class.
<style scoped>
.a :deep(.b) {
/* ... */
}
</style>
By default, scoped styles do not affect contents rendered by <slot/>. To explicitly target slot content, use the :slotted pseudo-class.
<style scoped>
:slotted(div) {
color: red;
}
</style>
In Vue.js, an active component instance will be unmounted when switching away from it by default. But what if you want to preserve the state when switching components? You can wrap it with the built-in <KeepAlive> component to preserve and cache the state. 💪🏻
<template>
<KeepAlive>
<component :is="activeComponent" />
</KeepAlive>
</template>
In Vue.js, you can pass multiple named slots to your child components.
<!-- Child component / Input.vue -->
<template>
<div class="input-wrapper">
<label>
<slot name="label" />
</label>
<input />
<div class="input-icon">
<slot name="icon" />
</div>
</div>
</template>
<!-- Parent component -->
<template>
<Input>
<template #label>
Email
</template>
<template #icon>
<EmailIcon />
</template>
</Input>
</template>
Thanks to the experimental component called 'Suspense', you can orchestrate async dependencies in a component tree. It can render a loading state while waiting for multiple nested async dependencies down the component tree to be resolved.
<template>
<Suspense>
<!-- component with nested async dependencies -->
<Dashboard />
<!-- loading state via #fallback slot -->
<template #fallback>
Loading...
</template>
</Suspense>
</template>
Nuxt Island is a specially built-in component that allows you to render the component entirely on the server, which means zero client-side JavaScript is served to the browser.
// This feature is still experimental so you have to enable it in nuxt.config
export default defineNuxtConfig({
experimental: {
componentIslands: true
}
})
Let's say that you have a JS-rich component, but you don't need the code of that library in your production bundle. One example could be using a heavy date manipulation library like moment.js. We just want to format some data and show users the result. It's a perfect use case for server components. You are running JS on the server and returning HTML without any JS to the browser.
<!-- components/Hello.vue -->
<template>
<div>
<h1>Hello</h1>
{{ date }}
</div>
</template>
<script setup lang="ts">
import moment from 'moment';
const date = moment().format('MMMM Do YYYY, h:mm:ss a');
</script>
All you have to do is move your component into the /components/islands directory and then call the component.
<!-- app.vue -->
<template>
<NuxtIsland name="Hello" />
</template>
In Vue, you can "teleport" a part of a component's template into a DOM node that exists outside the DOM hierarchy of that component. To do this, use the built-in Teleport component and target the specific DOM element you want to teleport the part of your template to.
<template>
<Teleport to="body">
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</Teleport>
</template>
In Vue, you can enable performance tracing in the browser devtool performance/timeline panel. This only works in development mode.
const app = createApp(App);
app.config.performance = true;
app.mount('#app');
In Vue, you can render components dynamically thanks to built-in <Component> component.
<script setup>
import UserSettings from './Foo.vue'
import UserNotifications from './Bar.vue'
const activeComponent = ref(UserSettings);
</script>
<template>
<component :is="activeComponent" />
</template>
Thanks to the 'callOnce' utility in Nuxt, you can execute a specified function or block of code once during server-side rendering (SSR) or client-side rendering (CSR).
<script setup lang="ts">
const websiteConfig = useState('config')
await callOnce(async () => {
console.log('This will only be logged once')
websiteConfig.value = await $fetch('https://my-cms.com/api/website-config')
})
</script>
In Vue, when you are passing a boolean type as a prop with an explicit true value, you can use the following shorthand.
<template>
<!-- you can use this -->
<BlogPost is-published />
<!-- instead of this -->
<BlogPost :is-published="true" />
</template>
By default, v-model syncs the input with the data after each input event. You can add the lazy modifier to instead sync after change events.
<!-- synced after "change" instead of "input" -->
<input v-model.lazy="msg" />
If you want user input to be automatically typecast as a number, you can add the number modifier to your v-model managed inputs.
<input v-model.number="age" />
If you want whitespace from user input to be trimmed automatically, you can add the trim modifier to your v-model-managed inputs.
<input v-model.trim="msg" />
You can expose your Nuxt development server to the internet thanks to local tunneling. All you have to do is run the Nuxt development server with the `--tunnel` flag.
npx nuxt dev --tunnel
You can run your Nuxt development server on the HTTPS protocol with a self-signed certificate.
npx nuxt dev --https
Components using `<script setup>` are closed by default. To explicitly expose properties in a `<script setup>` component, use the defineExpose compiler macro.
<script setup>
import { ref } from 'vue'
const a = 1
const b = ref(2)
defineExpose({
a,
b
})
</script>
In Nuxt, sometimes you would like to refresh the cookie value returned by a 'useCookie' composable. You can use the `refreshCookie` utility function available from Nuxt 3.10.
<script setup lang="ts">
const tokenCookie = useCookie('token')
const login = async (username, password) => {
const token = await $fetch('/api/token', { ... }) // Sets `token` cookie on response
refreshCookie('token')
}
const loggedIn = computed(() => !!tokenCookie.value)
</script>
In Vue, when your component template has some classes and you also add some classes to this component in the parent, the classes will be merged together.
parent component
<template>
<Table class="py-2"></Table>
</template>
child component Table.vue
<template>
<table class="border-solid border-2 border-sky-500">
<!-- ... -->
</table>
</template>
classes from the parent and child will be merged together
<template>
<table class="border-solid border-2 border-sky-500 py-2">
<!-- ... -->
</table>
</template>
In Vite, you can use Lightning CSS as your default transformer and minifier.
import {browserslistToTargets} from 'lightningcss';
export default {
css: {
transformer: 'lightningcss',
lightningcss: {
targets: browserslistToTargets(browserlist('>= 0.25%'))
}
},
build: {
cssMinify: 'lightningcss'
}
}
Console logging reactive items in Vue is not always intuitive. Turn on custom formatters and enjoy formatted console output for anything reactive.
You can enable custom formatters in Chrome (Chromium) DevTools by selecting the option "Console -> Enable custom formatters."