03-03-2025
development
Simple language changing in Next.js application
This post explains how I built a simple language switcher in Next.js using Zustand and a lang.json file - no i18n libraries needed. It supports toggling between English and Croatian, persists the language with localStorage, and uses a t(key) function for translations. Lightweight and perfect for dashboards or SPAs.
How to handle language switching in a Next.js app (without any i18n library)
For one of my personal projects (for this personal website as well 🙂), I needed a simple way to toggle between English and Croatian - nothing fancy, just a language switcher that remembers the selected language and updates the UI accordingly.
I didn’t want to pull in a full-blown i18n library like next-i18next. All I needed was:
- A
t(key)function for translations - `localStorage to persist the selection
- A lightweight global store to manage state
So I built it with Zustand and a simple
lang.json. Here’s the full setup.
The goal
- Let users switch between English and Croatian.
- Remember their selection using
localStorage. - Provide a simple
t(key)function for translating strings. - Avoid over-engineering with external i18n libraries.
The approach
We used three small pieces:
- A global language store using Zustand.
- A
lang.jsonfile with our translations. - A language switcher component that updates the language and reloads the page.
The code
1. useLangStore.ts – Global Zustand Store
import { create } from "zustand";
import translations from "@/constants/lang.json";
interface LanguageState {
selectedLanguage: string;
translations: Record<string, unknown>;
setSelectedLanguage: (language: string) => void;
t: (key: string) => string;
}
const useLangStore = create<LanguageState>((set) => {
let selectedLanguage = "en";
if (typeof window !== "undefined") {
selectedLanguage = localStorage.getItem("selectedLanguage") || "en";
}
const getNestedValue = (obj: unknown, path: string): unknown => {
return path.split(".").reduce((prev, curr) => {
if (prev && typeof prev === "object" && curr in prev) {
// @ts-expect-error: Index signature
return prev[curr];
}
return undefined;
}, obj);
};
return {
selectedLanguage,
translations: translations,
setSelectedLanguage: (language) => {
selectedLanguage = language;
set({ selectedLanguage: language });
if (typeof window !== "undefined") {
localStorage.setItem("selectedLanguage", language);
}
},
t: (key) => {
const lang = selectedLanguage as keyof typeof translations;
const translatedValue = getNestedValue(translations[lang], key);
return typeof translatedValue === "string" ? translatedValue : key;
},
};
});
export default useLangStore;
2. Translations (lang.json)
{
"en": {
"header": {
"title": "Welcome",
"subtitle": "Your dashboard"
}
},
"hr": {
"header": {
"title": "Dobro došli",
"subtitle": "Tvoja nadzorna ploča"
}
}
}
3. Language Switcher Component (change-lang.tsx)
"use client";
import { useEffect, useState } from "react";
import useLangStore from "@/store/useLangStore";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
const languageOptions = [
{ label: "Hrvatski 🇭🇷", value: "hr" },
{ label: "English 🇬🇧", value: "en" },
];
export default function ChangeLanguage() {
const setSelectedLanguage = useLangStore((state) => state.setSelectedLanguage);
const selectedLanguage = useLangStore((state) => state.selectedLanguage);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (!initialized) {
const storedLanguage = localStorage.getItem("selectedLanguage");
if (storedLanguage) {
setSelectedLanguage(storedLanguage);
}
setInitialized(true);
}
}, [initialized, setSelectedLanguage]);
const handleLanguageChange = (value: string) => {
localStorage.setItem("selectedLanguage", value);
window.location.reload();
setSelectedLanguage(value);
};
return (
<div>
<Select value={selectedLanguage} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[280px] mt-2 md:hidden">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languageOptions.map((option) => (
<SelectItem
className="capitalize"
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="hidden md:block">
<Button
onClick={() =>
handleLanguageChange(selectedLanguage === "en" ? "hr" : "en")
}
variant="ghost"
size="icon"
className="text-3xl"
>
{selectedLanguage === "hr" ? "🇭🇷" : "🇬🇧"}
</Button>
</div>
</div>
);
}
How to use it
Anywhere in your app, just use the t function:
import useLangStore from "@/store/useLangStore";
...
const { t } = useLangStore();
...
<h1 className="text-lg">{t("header.title")}</h1>
Final thoughts
This approach gives me everything I need - simple language switching with no extra dependencies, and full control over how translations work.
It works especially well for dashboards and SPAs where you don’t need SSR or route-based language handling. If I need SEO and server-side translations down the line, I might revisit next-i18next. But for now - this does the job perfectly.
Try it out on the home page of my site right now 🙂.
You can always view the code here: Github.