In this post we will discuss the i18n implementation and how it changes compared to the original V.2. For a better understanding of the basic functionalities, you will need to consult the other posts, or the original documentation on timlrx github.
Using the repo? Let me know and I'll start a list if you want your own blog listed there.
If you find this project useful, please give it a ⭐ to show your support.
I am currently redesigning my website, which uses the router page, and part of the code for the internationalized blog of V.1. I wanted to migrate to the app router, but for that, I first had to learn how to internationalize a site with the router app, so I took this repository as training.
And also, I love the idea of helping other developers to quickly start sharing their precious knowledge with the whole world, making the internet better, whether in their native language, or in English 😌
I also designed a much more complete template for artists, content creators and developers, which I m'using for my own site and which is available here :
Normal version :
I18 version :
My own website based on this new template :
This repository will sometimes be updated with new features (not present in the original repository)
For now :
-
Waline comment system now supported! It's probably the best open source comment system right now, with even i18n and many other great features! First, follow the official tutorial to set up the comments database and your vercel server. There's many options available, so take the time to read their documentation. Since it's Vue based, it's still compatible with Next.js, and I created a new comment component. You'll find it in "walinecomponents" folder. I also added a new css file, and you can modify the style here if needed. Once you have deployed your application for comments, modify the sitemetadata.js file. Set "iscomments" property to false, set "iswaline" to true and set the url of your comments server accordingly in "walineServer". If your language is not supported by waline, make a PR to their repository or ask them kindly to add your own translation (provide it yourself first). This is what I did for supporting french, and this how we work in open source world!
-
I know it's a frequently asked feature request in the original repository, so : series for your posts is now supported, see the deployed demo!
Example of including this new feature :
title: Internationalization of V 2.0
series:
order: 4 // You must add a number for the actual order of your post in your series
title: "Blog Starter" // You must add the same title to all of your posts from the same series
date: '2023-11-17'
lastmod: '2023-11-17'
language: en
localeid: 'introducingstarterblogi18n'
tags: ['next-js', 'tailwind', 'guide', 'features', 'i18n']
authors: ['default']
images: ['/static/images/twitter-card.png']
draft: false
summary: Presentation of the Starter Blog Tailwind Next-js v2.0, with addition and support for multiple languages.
-
Share component : you or your users can share your blog posts on Facebook, Twitter, Linkedin, WhatsApp, Threads or Telegram with ease! What's a 2024 modern blog without this possibility?
-
Smooth page transitions thanks to Framer Motion (see the template.tsx file in the app folder and take a look at the following next.js documentation for file functionality template) Note: This is a basic but effective implementation. I strongly encourage you to experiment with framer-motion and its use within the new router. I also added some Framer Motion flavor to the formspree contact modal, and to the ListLayoutWithTags.tsx component
-
New MDX component: excellent audio player for mdx files (in case you make podcasts, or even music), thanks to react-h5-audio-player
-
Tailwind screen size indicator: a little help for development mode and responsive design (see TwSizeIndicator.tsx in /components/helper)
-
Formspree support for the mail icon, with a beautiful modal dialog. Formspree allows your users to contact you and send you messages directly from your site, with anti-spam protection. Simply create a free basic account, read the docs and get the key from your formspree account and then replace the key with your own here, in components/formspree/index.tsx :
/* Line 19*/
const [state, handleSubmit, reset] = useForm('xdojkndq')
IMPORTANT NOTE: you must replace the key in useform like this : useform('[your key]'). The provided key is a test one of mine. You can use it to verify if the toast box is functional for example, but you must know that I'll receive all your test messages. (You can still send me a friendly hello message!)
If you don't want to use Formspree, go to the siteMetadata.js file and set formspree to "false".
For translations, the chosen library is not next-translate as in V.1 of GautierArcin, but the following libraries:
- i18next
- i18next-browser-languagedetector
- i18next-resources-to-backend
- React-i18next
Indeed, with the new version of next-js and the router app, it was easier for me to find information and tutorials to make everything work as expected. (I first tried with next-translate, but there are too many unresolved issues currently with this library and the features related to the new router)
Within the app folder, all content has been moved to a new folder [locale]: this is the official way recommended by next.js. An i18n folder has also been added:
app
│
[locale]
│
├── i18n
│ │
│ ├──locales
│ │ │
│ │ ├── en
│ │ │ ├── about.json
│ │ │ │
│ │ │ ├── home.json
│ │ │ │
│ │ │ └── ...
│ │ └── fr
│ │ ├── about.json
│ │ │
│ │ ├── home.json
│ │ │
│ │ └── ...
│ │
│ │
│ ├── client.ts
│ ├── locales.js
│ ├── server.ts
│ └── settings.ts
│
└── ...
It is therefore in this i18n folder that the main logic for the internationalization of the application is located.
- Json files:
The "locales" subfolder contains the .json files where you will define your translations, the convention being to define one file per page of your site, with the name of the page concerned for the name of the json file. There is also a "common" file: if you do not specify a "namespace" or ns (the name of the file without the json extension) in your pages or components, the translations will be taken from this file by default.
*Important: for each language there must be a corresponding file with the same name, for example an "about" file for "fr" and for "en", etc. As well as translation keys with the same name within of each file.
Example :
In English in the "en" folder:
{
"title": "Projects",
"description": "Showcase your projects with a hero image (16 x 9)",
"learn": "Learn more",
"subtitle": "Here you will find information about my current projects",
"linkto": "Link to"
}
In French in the "fr" folder:
{
"title": "Projets",
"description": "Présentez vos projets avec une image (16 x 9)",
"learn": "En savoir plus",
"subtitle": "Ici vous trouverez des informations sur mes projets actuels.",
"linkto": "Lien vers"
}
- locales.js:
This is the file where you will define the languages you want to use, as well as the default language:
const fallbackLng = 'en' // default language
const secondLng = 'fr'
module.exports = { fallbackLng, secondLng }
You can add as many languages as you want:
/* Example of adding a 3rd language:*/
const fallbackLng = 'en'
const secondLng = 'fr'
const thirdLng = 'es'
module.exports = { fallbackLng, secondLng, thirdLng }
However, this will require some additional configuration steps within other files (mainly files that are discussed here)
You can also swap the default language and the second language:
/* Example of changing default language:*/
const fallbackLng = 'fr'
const secondLng = 'en'
module.exports = { fallbackLng, secondLng}
- settings.ts
This is a configuration file, which allows you to define a locales object as well as the corresponding options:
import type { InitOptions } from 'i18next'
import { fallbackLng, secondLng } from './locales'
/* Locales object, which defines all the languages that will be used in the application: */
export const locales = [fallbackLng, secondLng] as const
/* Typescript definition of type for our locales:*/
export type LocaleTypes = (typeof locales)[number]
/* “Namespace” (or ns) by default: translations will be taken from the file
common.json if no ns is specified in your components or pages: */
export const defaultNS = 'common'
/* Function that will be reused in client.ts and server.ts files: */
export function getOptions(locale = fallbackLng, ns = defaultNS): InitOptions {
return {
debug:true,
supportedLngs: locales,
fallbackLng,
lng: locale,
fallbackNS:defaultNS,
defaultNS,
ns,
}
}
- client.ts and server.ts:
Without going into complex details, these two files each export a function for translation (useTranslation on the client side, createTranslation on the server side), reusable in your pages and components:
export function useTranslation(lng: LocaleTypes, ns: string) {
const translator = useTransAlias(ns)
const { i18n } = translator
/* Executed when content is rendered server-side: */
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
/* Use our custom implementation when running client-side: */
// eslint-disable-next-line react-hooks/rules-of-hooks
useCustomTranslationImplem(i18n, lng)
}
return translator
}
export async function createTranslation(lang: LocaleTypes, ns: string) {
const i18nextInstance = await initI18next(lang, ns)
return {
/* The "t" translation function that we will use in our components: */
// e.g. t('greeting')
t: i18nextInstance.getFixedT(lang, Array.isArray(ns) ? ns[0]: ns),
}
}
Example of client-side component, with translation of the button's aria-label:
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
/*Import the hook provided by next.js to retrieve the language
defined by the user, and the client-side translation function: */
import { useParams } from 'next/navigation'
import { LocaleTypes } from 'app/[locale]/i18n/settings'
import { useTranslation } from 'app/[locale]/i18n/client'
const ThemeSwitch = () => {
/* Using the hook provided by next.js to retrieve the currently defined language: */
const locale = useParams()?.locale as LocaleTypes
/* Using the client-side translation function:
no namespace (ns) defined (empty square brackets), therefore the translation will be drawn
in the common.json file */
const { t } = useTranslation(locale, '')
const [mounted, setMounted] = useState(false)
const { theme, setTheme, resolvedTheme } = useTheme()
useEffect(() => setMounted(true), [])
if (!mounted) {
return null
}
return(
<button
/* Translation of aria-label */
aria-label={t('darkmode')}
onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-6 w-6 text-gray-900 dark:text-gray-100"
>
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-. 707- .707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.7 07.707zm1.414 8.486 l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clipRule="evenodd"
/>
): (
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
)}
</svg>
</button>
)
}
export default ThemeSwitch
Example of server-side component:
import Link from './Link'
import siteMetadata from '@/data/siteMetadata'
import SocialIcon from '@/components/social-icons'
/*Importing the server-side translation function: */
import { createTranslation } from 'app/[locale]/i18n/server'
import { LocaleTypes } from 'app/[locale]/i18n/settings'
Props type = {
params: { locale: LocaleTypes }
}
/* the current language is passed as the page settings prop: */
export default async function Footer({ params: { locale } }: Props) {
/* Using the server-side translation function, with the "footer" namespace */
const { t } = await createTranslation(locale, 'footer')
return(
<footer>
<div className="mt-16 flex flex-col items-center">
<div className="mb-3 flex space-x-4">
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} />
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
<SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} />
<SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} />
</div>
<div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div>{siteMetadata.author}</div>
<div>{` • `}</div>
<div>{`© ${new Date().getFullYear()}`}</div>
<div>{` • `}</div>
<Link href="/">{siteMetadata.title}</Link>
</div>
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
{t('theme')}
</Link>
</div>
</div>
</footer>
)
}
For the creation of new components or pages, you will therefore have to rely on these two functions concerning your translations, depending on whether the component is rendered client-side or server-side.
- Middleware.ts:
Since I18n is not natively supported within the new router, it is an essential file for proper operation of the whole. It is also essential to use the "matcher" (here with inverted values which allow you to exclude folders and files which should not be supported by middleware)
import { NextResponse, NextRequest } from 'next/server'
import { locales } from 'app/[locale]/i18n/settings'
import { fallbackLng } from 'app/[locale]/i18n/locales'
export function middleware(request: NextRequest) {
/* Check if a language is supported in the pathname: */
const pathname = request.nextUrl.pathname
/* Check if default language is in pathname: */
if (pathname.startsWith(`/${fallbackLng}/`) || pathname === `/${fallbackLng}`) {
/* ex: the incoming request is: /en/about
The new path name is now: /about */
return NextResponse.redirect(
new URL(
pathname.replace(`/${fallbackLng}`, pathname === `/${fallbackLng}` ? '/' : ''),
request.url
)
)
}
const pathnameIsMissingLocale = locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
)
if (pathnameIsMissingLocale) {
/* We are on the default language
Rewriting so that next.js understands */
// ex: incoming request is: /about
// Inform Next.js that it should behave as if it were: /en/about
return NextResponse.rewrite(new URL(`/${fallbackLng}${pathname}`, request.url))
}
}
export const config = {
/* Do not execute middleware on the following paths: */
// prettier-ignore
match:
'/((?!api|static|data|css|scripts|.*\\..*|_next).*|robots.txt|sitemap.xml|favicon.ico)',
}
All posts are grouped within the “data/blog” folder.
They are organized into subfolders: "data/blog/posts" for English, and "data/blog/articles" for French (articles is the French translation of "posts"). If you add/modify a language, I simply recommend that you create a folder with the name of the translation of "posts" for the chosen language.
- Headers of your posts:
---
title: article title
date: creation date
lastmod: last modified date
language: article language
localeid: identifier to match articles in other languages
tags: tags
authors: authors
pictures: pictures
draft: under construction or not
summary: summary
---
- contentlayer.config.ts:
Within the "contentlayer.config.ts" file there are therefore minor changes due to internationalization:
export const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blog/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
language: { type: 'string', required: true }, // New required field
localeid: { type: 'string', required: true }, // New required field
tags: { type: 'list', of: { type: 'string' }, default: [] },
lastmod: { type: 'date' },
draft: { type: 'boolean' },
summary: { type: 'string' },
images: { type: 'json' },
authors: { type: 'list', of: { type: 'string' }, required: true },
layout: { type: 'string' },
bibliography: { type: 'string' },
canonicalUrl: { type: 'string' },
},
...
)})
For the “authors” field:
export const Authors = defineDocumentType(() => ({
name: 'Authors',
filePathPattern: 'authors/**/*.mdx',
contentType: 'mdx',
fields: {
name: { type: 'string', required: true },
language: { type: 'string', required: true }, // New required field
avatar: { type: 'string' },
occupation: { type: 'string' },
company: { type: 'string' },
email: { type: 'string' },
twitter: { type: 'string' },
linkedin: { type: 'string' },
github: { type: 'string' },
layout: { type: 'string' },
},
-Generation of tags:
Here too, it was necessary to make modifications in order to generate a .json object with tags for each language.
function createTagCount(allBlogs) {
const tagCount = {
[fallbackLng]: {},
[secondLng]: {},
}
allBlogs.forEach((file) => {
if (file.tags && (!isProduction || file.draft !== true)) {
file.tags.forEach((tag: string) => {
const formattedTag = GithubSlugger.slug(tag)
if (file.language === fallbackLng) { // tags for default language
tagCount[fallbackLng][formattedTag] = (tagCount[fallbackLng][formattedTag] || 0) + 1
} else if (file.language === secondLng) { // tags for the second language
tagCount[secondLng][formattedTag] = (tagCount[secondLng][formattedTag] || 0) + 1
}
})
}
})
writeFileSync('./app/[locale]/tag-data.json', JSON.stringify(tagCount))
}
Note: If you want to add other languages (3, 4 or even 5 languages), you will need to modify the logic to support these new languages.
-generateSlugMap function:
async function generateSlugMap(allBlogs) {
const slugMap = {}
// Processing each blog post
allBlogs.forEach((blog) => {
const { localeid, language, slug } = blog
const formattedLng = language === fallbackLng ? fallbackLng: secondLng
if (!slugMap[localeid]) {
slugMap[localeid] = {}
}
// Adding the slug to the mapping for the specific language
slugMap[localeid][formattedLng] = slug
})
writeFileSync('./app/[locale]/localeid-map.json', JSON.stringify(slugMap, null, 2))
}
This function is the one that allows you to match the posts to each other:
let's say you are reading a post, but you prefer to read it in another language, instead of redirecting to a 404 error page, this will display the post in the corresponding language!
But for this, you must assign a unique identifier (LocaleID in your MDX files) to your post, and the corresponding post in the translated language. If no matching item is found, the router will simply redirect you to the blog's overview page.
- Important: if you are a Windows user, a script adapted to internationalization is provided (/scripts folder) as a workaround to a beug linked to the contentlayer library.
Folders containing authors are organized by language, and author information can be translated.
The implementation is quite simple and straightforward: if you want to change or add a language, just change or add folders with your corresponding translations for new languages.
The siteMetadata.js file present in the "/data" folder does not require modifications related to internationalization.
On the other hand, in order to best manage the metadata, it was necessary to create a new file, for the title and the description:
Metadata type = {
[locale: string]: string
}
/* Add or modify the title here depending on the chosen languages: */
export const maintitle: Metadata = {
en: 'Next.js i18n Starter Blog',
fr: 'Starter Blog Next.js i18n',
}
/* Add or modify the description here depending on the chosen languages: */
export const maindescription: Metadata = {
en: 'A blog created with Next.js, i18n and Tailwind.css',
fr: 'Un blog crée avec tailwind, i18n et next.js',
}
The logic needed for the "projects" tab resides in the following file, also present in the "/data" folder:
type Project = {
title: string
description: string
imgSrc: string
href: string
}
type ProjectsData = {
[locale:string]:Project[]
}
const projectsData: ProjectsData = {
en: [
{
title: 'A Search Engine',
description: `What if you could look up any information in the world? Webpages, images, videos
and more. Google has many features to help you find exactly what you're looking for
for.`,
imgSrc: '/static/images/google.png',
href: 'https://www.google.com',
},
{
title: 'The Time Machine',
description: `Imagine being able to travel back in time or to the future. Simple turn the knob
to the desired date and press “Go”. No more worrying about lost keys or
forgotten headphones with this simple yet affordable solution.`,
imgSrc: '/static/images/time-machine.jpg',
href: '/blog/posts/the-time-machine',
},
],
fr: [
{
title: 'Un moteur de recherche',
description: `Et si vous pouviez rechercher n'importe quelle information dans le monde ? Pages Web, images, vidéos
et plus. Google propose de nombreuses fonctionnalités pour vous aider à trouver exactement ce que vous cherchez.`,
imgSrc: '/static/images/google.png',
href: 'https://www.google.com',
},
{
title: 'La Machine à remonter le temps',
description: `Imaginez pouvoir voyager dans le temps ou vers le futur. Tournez simplement le bouton
à la date souhaitée et appuyez sur "Go". Ne vous inquiétez plus des clés perdues ou
écouteurs oubliés avec cette solution simple mais abordable.`,
imgSrc: '/static/images/time-machine.jpg',
href: '/blog/articles/la-machine-a-remonter-le-temps',
},
],
}
export default projectsData
Again, simply modify the logic keeping the same general structure, and according to your chosen languages/and/or number of languages.
There too, there were necessary modifications. This file is located in the "app" folder, and allows Google robots to understand how your site is built, so it is essential for indexing and SEO:
import { MetadataRoute } from 'next'
import { allBlogs } from 'contentlayer/generated'
import siteMetadata from '@/data/siteMetadata'
import { fallbackLng, secondLng } from './i18n/locales'
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = siteMetadata.siteUrl
// routes for blog posts in English
const blogRoutes = allBlogs
.filter((p) => p.language === fallbackLng)
.map((post) => ({
url: `${siteUrl}/${fallbackLng}/${post.path}`,
lastModified: post.lastmod || post.date,
}))
// routes for blog posts in French (or your own second language)
const secondBlogRoutes = allBlogs
.filter((p) => p.language === secondLng)
.map((post) => ({
url: `${siteUrl}/${secondLng}/${post.path}`,
lastModified: post.lastmod || post.date,
}))
// all other routes for English
const routes = ['', 'blog', 'projects', 'tags', 'about'].map((route) => ({
url: `${siteUrl}/${fallbackLng}/${route}`,
lastModified: new Date().toISOString().split('T')[0],
}))
// all routes for French (or your own second language)
const secondRoutes = ['', 'blog', 'projects', 'tags', 'about'].map((route) => ({
url: `${siteUrl}/${secondLng}/${route}`,
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...secondRoutes, ...blogRoutes, ...secondBlogRoutes]
}
Note: modify this file accordingly if you add other languages.
The original repository allows support for kbar and algolia.
Here, the search bar relies on the kbar library, and Algolia support is not planned. If you prefer to use Algolia, it will be up to you to implement it on your site, instead of kbar.
There's an issue when using regular translations, so I implemented a workaround for that problem. Just modify the name in each menu item, as well as the navigationSection object, based on the languages you're using.
export const SearchProvider = ({ children }: SearchProviderProps) => {
const locale = useParams()?.locale as LocaleTypes
const { t } = useTranslation(locale, '')
const router = useRouter()
/* issue when using regular translations, this is a workaround to show how to implement section titles */
/*Modify the following line based on your implemented languages: */
const navigationSection = locale === fallbackLng ? 'Navigate' : 'Naviguer'
return (
<KBarSearchProvider
kbarConfig={{
searchDocumentsPath: 'search.json',
defaultActions: [
{
id: 'home',
/*Modify the following line based on your implemented languages: */
name: locale === fallbackLng ? 'Home' : 'Accueil',
keywords: '',
shortcut: ['h'],
section: navigationSection,
perform: () => router.push(`/${locale}`),
},
{
id: 'blog',
/*Modify the following line based on your implemented languages: */
name: locale === fallbackLng ? 'Blog' : 'Blog',
keywords: '',
shortcut: ['b'],
section: navigationSection,
perform: () => router.push(`/${locale}/blog`),
},
{
id: 'tags',
/*Modify the following line based on your implemented languages: */
name: locale === fallbackLng ? 'Tags' : 'Tags',
keywords: '',
shortcut: ['t'],
section: navigationSection,
perform: () => router.push(`/${locale}/tags`),
},
{
id: 'projects',
/*Modify the following line based on your implemented languages: */
name: locale === fallbackLng ? 'Projects' : 'Projets',
keywords: '',
shortcut: ['p'],
section: navigationSection,
perform: () => router.push(`/${locale}/projects`),
},
{
id: 'about',
/*Modify the following line based on your implemented languages: */
name: locale === fallbackLng ? 'About' : 'À propos',
keywords: '',
shortcut: ['a'],
section: navigationSection,
perform: () => router.push(`/${locale}/about`),
},
],
onSearchDocumentsLoad(json) {
return json
.filter((post: CoreContent<Blog>) => post.language === locale)
.map((post: CoreContent<Blog>) => ({
id: post.path,
name: post.title,
keywords: post?.summary || '',
section: t('content'),
subtitle: post.tags.join(', '),
perform: () => router.push(`/${locale}/${post.path}`),
}))
},
}}
>
{children}
</KBarSearchProvider>
)
}
-
Fix the translation in page 404. This is related to the current functioning of the not-found function, so we have to wait for a fix from next-js side see here: i18n for not-found page
-
Fix Robot.ts, sitemap.ts and rss.mjs (I don't have that much time, so if you end up with a solution, please make a pr or open a new issue)
Everything else is currently working as expected.
So I had to make significant changes regarding SEO and metadata within the pages, but I ended up finding a solution which worked, with a perfect SEO score!
Here is another possible solution for i18n integration regarding SEO, and even the translated URL:
Any help for improvements and/or bug reports is welcome!
Important notes:
-
I use a custom Link component for language selection: I prefer this to the HTML selection element (easier to customize). The small downside is that it requires more code. If you prefer, you are free to adapt and use the select element instead, but I I'll keep it as is for the template.
-
Do not update dependencies: this will break your application since some things need to be fixed on the side of these libraries
Author: pxlsyl