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."