Guides
URL strategy
Two ways to carry the language: in the address, or not at all. This page is about picking one on purpose, because the choice decides your SEO and it is hard to change later.
The two strategies
| Strategy | What the visitor sees | Switching language is |
|---|---|---|
| In the address | /docs in English, /es/docs in Spanish | A navigation to that address |
| Not in the address | /docs in every language | The text changing where it is |
Verbaly supports both. This site puts the language in the address on every page, because people reach documentation through search. An app behind a login is the other case: it changes language with one call and no navigation, from a single address. The strategy follows the surface, not your taste.
Give the mode a name
You usually write nothing. Verbaly reads your setup: if you build a version of your site per language you get addresses per language, and if you do not, one address serves all of them. Say it out loud only to disagree.
export default {
sourceLocale: 'en',
locales: ['en', 'es', 'pt'],
routing: 'prefix-all', // only when you disagree with what it read
};| Mode | The address | When |
|---|---|---|
prefix-except-source | /docs and /es/docs | Anything people reach through search, which is most public sites |
prefix-all | /en/docs and /es/docs | When no language is the house language and every one is a guest |
no-prefix | /docs in every language | Behind a login, where nobody arrives from a search result |
Switching is one line, and it is the same line in every mode. Import it from virtual:verbaly, where it already knows your languages, your source and your mode.
import { switchLocale } from 'virtual:verbaly';
await switchLocale('es');With the language in the address it goes to that address, which is already written in Spanish, so there is no waiting and nothing flashes. Without it, the text changes where it stands and the address never moves. Either way it remembers the choice and sets the page language. Give it your framework's router and your app survives the switch:
await switchLocale('es', { navigate: (path) => router.push(path) });npx verbaly doctor says which mode you are in and whether it read that from your config or worked it out, so a mode nobody wrote down never looks like a decision somebody made. It also tells you when your settings ask for two things that cannot both be true.
The head is half of what a search result shows
A page can be translated everywhere a visitor looks and still tell a search engine it is in English, because the title and the description live in the <head> and nobody reads them on the page. That is most of the reason to give a language its own address, so bind them like anything else.
<title data-verbaly="page.title">URL strategy</title>
<meta name="description" content="…" data-verbaly-attr='{"content":"page.desc"}'>verbaly render fills them per language like the rest of the page, and counts the pages whose title never varies so you hear about it once instead of finding out from a search result. It never fails a build: a product name is allowed not to translate.
The one difference that cannot be worked around
A search engine indexes addresses, not languages. If your Spanish and your English live at the same address, only one of them gets indexed, and no amount of runtime cleverness changes that: hreflang annotations exist precisely to connect different addresses, so without them there is nothing to connect. Google's own guidance is to use different URLs per language rather than cookies or browser settings.
What Verbaly gives you, by how you render
Keeping the language stable across a click and across a refresh is a different problem depending on who builds the HTML. These are the three cases and what each one needs.
| You render | In the address | Not in the address |
|---|---|---|
| In the browser, a SPA with client routing | localeFromPath reads the language from the address on every route change | resolveLocale + persistLocale. Clicking never reloads, and a refresh reads the saved choice back |
| On a server, SvelteKit, Nuxt or Next.js | Your framework's router owns the segment, Verbaly owns the catalogs | resolveRequestLocale reads the cookie per request, so the document arrives already translated. Wired for you in all three integrations |
| At build time, Astro, Eleventy or any SSG | verbaly render writes your site in every language, with hreflang and a sitemap | Nothing can do this well. One document is served to everyone, so the language can only change after it arrives |
Why the last cell is empty everywhere, not just here
A static host sends the same file to every visitor. To show another language without changing the address, something has to run before the HTML is sent, and on a static site nothing does. The libraries that offer this mode all solve it with a server reading a cookie: next-intl rewrites in middleware, and says plainly that with Next.js static export the middleware does not run at all. Verbaly does not pretend otherwise. Your options, in the order we would try them:
- Put it in the address.
verbaly render, andrender.redirectso a visitor lands in their language without ever seeing the choice happen. This is what this site does. - Use a subdomain per language.
es.example.comhas no segment in the path and still has its own address, so search keeps working. This is the option people forget. - Rewrite at the edge. Your host can serve the pre-rendered
/es/file at the plain address based on a cookie. It works, and it costs you: one language indexed, and a cache entry per cookie value on your CDN. - Accept the swap. Ship the source language and let
bindDomreplace the text once the catalog loads. Fine behind a login screen, visible on a cold page load.
Keeping one address, in practice
In the browser it is two calls. resolveLocale picks the language for a visitor who arrives with no address to read it from, and persistLocale remembers the switch so the next refresh agrees.
import { createVerbaly, persistLocale, resolveLocale } from 'verbaly';
const supported = ['en', 'es', 'pt'];
export const i18n = createVerbaly({
locale: resolveLocale({ supported, path: false }), // no language in the address
fallback: 'en',
loaders: { es: () => import('./locales/es.json') },
});
export async function switchTo(locale) {
await i18n.loadLocale(locale); // catalog first, so the text never flashes
i18n.setLocale(locale);
persistLocale(locale); // storage + <html lang> + <html dir>
}path: false is the part worth knowing. By default resolveLocale reads the first segment of the address before anything else, because on a site that does put the language there, the address is the only source that cannot contradict the page. Turn it off when your addresses are not languages, and a route called /es can never be mistaken for Spanish.
On a server the same idea moves one step earlier: resolveRequestLocale reads the cookie, falls back to Accept-Language, and hands the locale to the instance that renders the response, so the visitor never receives a document in the wrong language. @verbaly/sveltekit, @verbaly/nuxt and @verbaly/next already do this; switchLocale is the client half and writes the cookie the server will read.
// the same decision, made before the html exists
const locale = resolveRequestLocale({ supported, cookie, header });Changing your mind later
Going from one address to many is cheap: turn on render, and every language gets its own address plus hreflang. Going the other way costs you the addresses you already published, and search engines will keep them for a long time, so redirect rather than delete. Neither direction touches your messages, your keys or your catalogs: this is a routing decision, not a translation one.