Building A Persistent Multilingual Toast Component In Nuxt
Picture this: You've just rolled out an exciting new feature on your application. You want to announce it to your users, but you don't want to bombard them with the same message every time they visit. Oh, and your app serves users across multiple languages. Sound familiar?
Toast notifications are a common UI pattern for displaying temporary messages to users. But when you need them to be smarter - showing up just once, speaking your users' language, and knowing when to gracefully disappear - things get interesting.
In this tutorial, we'll build a toast component from the ground up, progressively adding features you'll actually use in production. We'll start with a basic implementation, then enhance it with internationalization (i18n) support for multiple languages, and finally add localStorage persistence so it remembers when it's been shown.
By the end of this guide, you'll have:
- A reusable toast component with a clean render function pattern
- Full internationalization support for multilingual messages
- Persistent state management using localStorage
- A deep understanding of how these features work together
Installation and setup
We'll begin with creation a new Nuxt application:
npx nuxi@latest init multilingual-toast-nuxt
And then let's add a few modules using the handy nuxi module add
cli commands.
i18n
Install the i18n module to make adding internationalization easier:
npx nuxi@latest module add i18n
shadcn-nuxt
Also install the shadcn-nuxt module. I use this module often for UI components. This is also how I will be integrating the toast component.
This installation has a few steps that are important. You can read about each dependency in their docs.
Shadcn has a bug that requires manually installing Typescript as a dev dependency:
npm install -D typescript
Shadcn relies on Tailwindcss:
npx nuxi@latest module add @nuxtjs/tailwindcss
Now add the shadcn-nuxt module:
npx nuxi@latest module add shadcn-nuxt
Note: At the time of writing this article (Dec. 14, 2024), there is this issue with the error Nuxt module should be a function: @nuxtjs/color-mode
. If you run into this error, a simple fix is navigating to your package.json
file and replacing your shadcn-nuxt
dependency with this version: "shadcn-nuxt": "^0.10.4",
Run the Shadcn init command to finish the installation:
npx shadcn-vue@latest init
Toast component and utilities
Now, we you should be able to install Shadcn components using their cli. For this tutorial, we're just going to use their toast component.
npx shadcn-vue@latest add toast
This should create files within your components/ui/toast
directory as well as a utils.ts
file in your lib
directory.
Navigate to your app.vue
file and replace all of the content within the file with this code:
// app.vue
<script setup lang="ts">
import Toaster from '@/components/ui/toast/Toaster.vue'
</script>
<template>
<Toaster />
</template>
This is the base-amount of code needed to get the Toast
component to show in other areas of your app. For this tutorial, we're going to use a simple example and keep all of the code within our app.vue
file.
With this in mind, extend this code with the following:
// app.vue
<script setup lang="ts">
import { Toaster } from '@/components/ui/toast'
import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast()
onMounted(() => {
toast({
title: 'Hello toast',
description: "Let's spread some butter on ya.",
})
})
</script>
<template>
<Toaster />
</template>
This imports the useToast
composable and extracts the toast
function from it.
We then call toast()
. In this example, I run it in an onMounted
call for ease-of-use.
This is typically how I use it, but it's ultimately just a function that can be called as needed.
The nice thing about Shadcn components is that they are always responsive. The example shown above is how it's shown on mobile devices.
And here it is on a desktop:
Adding internationalization (i18n)
Let's implement internationalization. With modern AI tooling, managing translation files is a breeze and something I try to include in every application I build.
We installed i18n earlier in our i18n section, so let's just right to the fun stuff.
Extend your nuxt.config.ts
file to include i18n-specific configuration so that it looks like the following:
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: ['@nuxtjs/i18n', '@nuxtjs/tailwindcss', 'shadcn-nuxt'],
i18n: {
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'nuxt_i18n',
},
defaultLocale: 'eng_Latn',
locales: [],
},
})
This is my go-to configuration. Here are the i18n.nuxtjs.org docs to better understand each option if you are curious.
English i18n file
We need to create the file that will contain all of the English words that our website is going to use. The pattern we implement here will be reproducible and used for every additional language you use.
I personally prefer a dedicated i18n
directory that contains my translation files. My translations files also contain explicit information regarding their intention. For example, instead of eng.json
, I prefer eng_Latn
. These details are up to you, this is just how I prefer to organize my projects.
Our toast message contains two string:
title: 'Hello toast',
description: "Let's spread some butter on ya.",
Let's re-create this within an eng_Latn.json
file:
// i18n/eng_Latn.json
{
"title": "Hello toast",
"description": "Let's spread some butter on ya.",
}
i18n files can get cluttered as the project grows, so I structure my JSON a bit more explicitly. In this case, I'll place these strings nested within a toast
object:
// i18n/eng_Latn.json
{
"toast": {
"title": "Hello toast",
"description": "Let's spread some butter on ya."
}
}
Now, in our nuxt.config.ts
file, let's add this new language locale
:
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: ['@nuxtjs/i18n', '@nuxtjs/tailwindcss', 'shadcn-nuxt'],
i18n: {
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'nuxt_i18n',
},
defaultLocale: 'eng_Latn',
locales: [{ code: 'eng_Latn', title: 'English' }],
},
})
For any future language you add, make sure you create a dedicated locales
object for it. nuxt.config.ts
is a typescript file, so you can always get creative with how you iterate over your i18n files to generate an array here.
i18n.config.ts
Nuxt's i18n module relies on vueI18n under the hood. We will include an i18n.config.ts
file at the root of our project to specifically configure the vueI18n options.
// i18n.config.ts
import eng_Latn from './i18n/eng_Latn.json'
export default defineI18nConfig(() => ({
legacy: false,
locale: 'eng_Latn',
messages: {
eng_Latn: eng_Latn,
},
}))
This is where we import the eng_Latn
JSON object we created earlier, passing it to our i18n
config.
Template integration
We can now bring the translations directly into our template files now that we are finished configuration i18n in our Nuxt application.
Nuxt i18n provides a handy useI18n
composable that allows us to manage translations within our script setup
.
Here is our new app.vue
with i18n in place:
// app.vue
<script setup lang="ts">
import { Toaster } from '@/components/ui/toast'
import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast()
const { t } = useI18n()
onMounted(() => {
toast({
title: t('toast.title'),
description: t('toast.description'),
})
})
</script>
<template>
<Toaster />
</template>
Not bad, right?
Persisting state using local storage
You (probably) don't want your toast to appear every time your user navigates to your site.
A common solution is to store some data in local storage, persisting state between user sessions. I only want to show the toast the first time a user navigates to our site.
useLocalStorage utility functions
I decided to ask chatGPT create a useLocalStorage
utility function to create, get, and remove values from local storage:
// lib/useLocalStorage.ts
export const useLocalStorage = () => {
const setValue = (key: string, value: any): void => {
if (process.client) {
localStorage.setItem(key, JSON.stringify(value))
}
}
const getValue = <T>(key: string, defaultValue?: T): T | null => {
if (process.client) {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : defaultValue || null
}
return defaultValue || null
}
const removeValue = (key: string): void => {
if (process.client) {
localStorage.setItem(key, '')
}
}
return {
setValue,
getValue,
removeValue,
}
}
Persisting our toast's state
Now we're going to edit the Toaster.vue
component that Shadcn installed for us. You can find the component's file in your components/ui/toast
directory.
Toaster.vue
is where we will set the value in our value. I'm taking a simple approach and simply saying, when the toast is closed, set a local storage value toast:shown
to true.
// Toaster.vue
<script setup lang="ts>
import { isVNode } from 'vue'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '.'
import { useToast } from './use-toast'
const { toasts } = useToast()
const { setValue } = useLocalStorage()
const handleOpenEvent = async () => {
setValue('toast:shown', true)
}
</script>
We're going to make use of the @update:open
event that Toast
emits. Here are the docs if you want to learn about each of the events our component supports.
// Toaster.vue
<template>
<ToastProvider>
<Toast
v-for="toast in toasts"
:key="toast.id"
v-bind="toast"
@update:open="handleOpenEvent" <!--- Add this
>
<div class="grid gap-1">
<ToastTitle v-if="toast.title">
{{ toast.title }}
</ToastTitle>
<template v-if="toast.description">
<ToastDescription v-if="isVNode(toast.description)">
<component :is="toast.description" />
</ToastDescription>
<ToastDescription v-else>
{{ toast.description }}
</ToastDescription>
</template>
<ToastClose />
</div>
<component :is="toast.action" />
</Toast>
<ToastViewport />
</ToastProvider>
</template>
Note: I linked to Radix Vue because Shadcn is built on top of Radix Vue. Shadcn is primarily a UI layer while Radix is where you may want to look to gain a better understanding of the state and events these components support.
Show/hide toast based on local storage state
Okay, now we are setting the toast:shown
value to true when we close our toast. But now, we need to check on this value prior to showing our toast.
Lucky for us, the heavy lifting is already gone and we just need to do a simple check prior to calling our toast
function.
// app.vue
<script setup lang="ts">
import { Toaster } from '@/components/ui/toast'
import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast()
const { t } = useI18n()
const { getValue } = useLocalStorage()
onMounted(() => {
const toastShown = getValue('toast:shown')
if (!toastShown) {
toast({
title: t('toast.title'),
description: t('toast.description'),
})
}
})
</script>
<template>
<Toaster />
</template>
We check to see if toast:shown
is true, meaning it's been shown based on our Toaster.vue
logic.
If it is, do not show the toast. Otherwise, show it.
Conclusion
And there you have! A multilingual toast component that has persistence built in.
This is a common pattern I use in nearly all of my web apps and showcases a few powerful tools such as i18n, Shadcn, and local storage management.
I hope you enjoyed it. Let me know if you have any tips, suggestions, or critique!
You can find the Github repository here.