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

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.

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.

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 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:

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

Copied to clipboard