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.
With the language in the address it goes to that address, keeping the query and the anchor, and that page 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:
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 you look and still tell a search engine it is in English. The title and the description live in the <head>, where nobody sees them on the page, so they are easy to forget. Bind them like any other text: they are most of what a search result shows.
<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.
When the address carries the language, it wins
Once the language is in the address, that is the answer, and nothing overrides it. Someone opens /es/pricing and gets Spanish, even if they picked English here yesterday. An address gets shared, linked and cached; a saved choice belongs to one person. The other half matters just as much: a page without a prefix is your source language, so someone who once picked Spanish will never find your English pages quietly translated.
The saved choice and the browser language still decide one thing, and only one: where to send someone who arrives without a language in the address. See Server-side for the wiring per framework.
The one difference that cannot be worked around
Search engines index addresses, not languages. If your Spanish and your English share one address, only one of them can get indexed, and nothing you do at runtime changes that. hreflang is how you tell a search engine that two addresses are the same page in different languages, so with a single address there is nothing to connect. Google says the same: one URL per language, not a cookie and not a browser setting.
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.
The address buys you one more thing. With render.inlineCatalog, each page carries only the messages it shows, so someone reading one page in a translated language no longer downloads that whole language.
One thing to know if you route on the client: each page carries its own messages, so your router has to hand them over as it swaps documents, with adoptPage from virtual:verbaly. Astro's <ClientRouter /> is wired for you.
The prefix reaches every link, including the ones written inside a sentence. A message that names a link (<docs>the guide</docs>) is treated exactly like an <a> you wrote yourself. So a reader who clicks mid-paragraph stays in their language instead of landing on your source one.
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 happens one step earlier. resolveRequestLocale reads the cookie, falls back to Accept-Language, and hands the language to the instance that renders the response, so nobody ever receives a page in the wrong one. @verbaly/sveltekit, @verbaly/nuxt and @verbaly/next already do this for you. switchLocale is the other half, in the browser: it writes the cookie the server will read.
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.