Vue Nuxt
Tips & Tricks

A collection of Vue, Nuxt and Vite tips, tricks and good practices.

vue
nuxt
vite
<!-- 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> 
<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> 
<template> <!-- You can now shorten this: --> <img :id="id" :src="src" :alt="alt">  <!-- To this: --> <img :id :src :alt> </template> 
const state = shallowRef({ count: 1 })  // does NOT trigger change state.value.count = 2  // does trigger change state.value = { count: 2 } 
const emit = defineEmits<{  change: [id: number]  update: [value: string] }>() 
<style scoped> button {  background-color: v-bind(backgroundColor); } </style> 
// 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; } 
<style scoped> :global(.red) {  color: red; } </style> 

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!

<script setup lang="ts"> export interface Props {  variant?: 'primary' | 'secondary'  disabled?: boolean }  const props = withDefaults(defineProps<Props>(), {  variant: 'primary',  disabled: false }) </script> 

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> 
<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> 
<script setup> // enables v-focus in templates const vFocus = {  mounted: (el) => el.focus() } </script>  <template>  <input v-focus /> </template> 
// 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' 
// 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); }); 
// 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; }); 
<style scoped> .a :deep(.b) {  /* ... */ } </style> 
<style scoped> :slotted(div) {  color: red; } </style> 
<template> <KeepAlive>  <component :is="activeComponent" /> </KeepAlive> </template> 
<!-- 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> 
<template>  <Suspense>  <!-- component with nested async dependencies -->  <Dashboard />   <!-- loading state via #fallback slot -->  <template #fallback>  Loading...  </template>   </Suspense> </template> 
// 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> 
<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> 
const app = createApp(App);  app.config.performance = true;  app.mount('#app'); 
<script setup> import UserSettings from './Foo.vue' import UserNotifications from './Bar.vue'  const activeComponent = ref(UserSettings); </script>  <template>  <component :is="activeComponent" /> </template> 
<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> 
<template>  <!-- you can use this -->  <BlogPost is-published />   <!-- instead of this -->  <BlogPost :is-published="true" /> </template> 
<!-- synced after "change" instead of "input" --> <input v-model.lazy="msg" /> 
<input v-model.number="age" /> 
<input v-model.trim="msg" /> 
npx nuxt dev --tunnel 
npx nuxt dev --https 
<script setup> import { ref } from 'vue'  const a = 1 const b = ref(2)  defineExpose({  a,  b }) </script> 
<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> 

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> 
import {browserslistToTargets} from 'lightningcss';  export default {  css: {  transformer: 'lightningcss',  lightningcss: {  targets: browserslistToTargets(browserlist('>= 0.25%'))  }  },  build: {  cssMinify: 'lightningcss'  } } 

You can enable custom formatters in Chrome (Chromium) DevTools by selecting the option "Console -> Enable custom formatters."