Skip to content

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

StrategyWhat the visitor seesSwitching language is
In the address/docs in English, /es/docs in SpanishA navigation to that address
Not in the address/docs in every languageThe 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.

verbaly.config.mjs
export default {
  sourceLocale: 'en',
  locales: ['en', 'es', 'pt'],
  routing: 'prefix-all', // only when you disagree with what it read
};
ModeThe addressWhen
prefix-except-source/docs and /es/docsAnything people reach through search, which is most public sites
prefix-all/en/docs and /es/docsWhen no language is the house language and every one is a guest
no-prefix/docs in every languageBehind 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, 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:

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.

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.

head.html
<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 renderIn the addressNot in the address
In the browser, a SPA with client routinglocaleFromPath reads the language from the address on every route changeresolveLocale + persistLocale. Clicking never reloads, and a refresh reads the saved choice back
On a server, SvelteKit, Nuxt or Next.jsYour framework's router owns the segment, Verbaly owns the catalogsresolveRequestLocale 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 SSGverbaly render writes your site in every language, with hreflang and a sitemapNothing 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:

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.

src/i18n.ts
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.

// 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.

↑↓to moveEnterto open
Copied to clipboard