Language lives in the URL: Turkish at the root, English under /en
When I made the site bilingual I did not leave the language to localStorage or the browser setting; I put it in the address. Turkish sits at the root, English under /en; the router reads the language from the URL and LocLink keeps every link in the language you are reading.
01Why the address?
In the first version the language lived in a small typed store and the preference was written to localStorage under the key dvx-lang. It worked for visitors but got stuck in three places. First, search engines: when one address serves content in two languages, which one gets indexed is unclear. Second, sharing: a link I sent to someone who reads English could open in Turkish in their browser. Third, prerendering: the build turns every route into static HTML, and there is no localStorage on the server. For the server to know which language to render, the language had to be part of the request itself.
I also considered reading the browser language and redirecting, but that brings the same problem back through another door: the same URL shows different content to different people. I wanted every page to have its own address in each language. Turkish is the default, so it stayed at the root (/work), and English got the /en prefix (/en/work).
02Copying the routes
I did not write two route lists. The Turkish routes are the single source; the English ones are a copy produced with map. The path gets /en in front, the name gets the en- prefix, and lang: 'en' is written into meta. Home becomes /en, and the catch-all route used for 404 becomes /en/:pathMatch(.*)*.
const english = base.map((r) => ({
...r,
path: r.path === '/' ? '/en' : r.path === '/:pathMatch(.*)*' ? '/en/:pathMatch(.*)*' : '/en' + r.path,
name: 'en-' + String(r.name),
meta: { ...r.meta, lang: 'en' as const }
})) as RouteRecordRaw[]
export const routes: RouteRecordRaw[] = [...english.slice(0, -1), ...base.slice(0, -1), english[english.length - 1], base[base.length - 1]]
// makeRouter(server): runs on the server render and in the browser
router.beforeEach((to) => {
setLocale(localeFromPath(to.path))
return true
})Order matters here. Both catch-all routes go to the end of the list, and the English one comes first; otherwise an unknown address under /en would fall into the Turkish 404. For project and article slugs that do not exist, a helper called notFound runs inside beforeEnter: it keeps the address the visitor typed and renders the 404 view in the right language.
03The URL decides
The actual decision lives in a single beforeEach. On every navigation the router looks at to.path, derives the language with localeFromPath and calls setLocale. This hook is deliberately installed before the server check, so it runs both during the server render at build time and in the browser. The server uses memory history, the browser uses web history; the language decision comes from the same line in both.
This matters for hydration too. The language store always starts in Turkish, but before the first render the router steps in and writes the language from the address. In main.ts, mounting waits for router.isReady(). So /en/work renders in English on the server and on the client, and the markup matches exactly. The comment in main.ts says as much: nothing is left to do for language after mount.
04Path helpers
All conversions live in three small functions in src/i18n/paths.ts. localeFromPath returns en if the path is exactly /en or starts with /en/, and tr otherwise. basePath strips the prefix and gives the language-neutral path, and localize turns that path into the target language.
export const localeFromPath = (path: string): Locale => (path === '/en' || path.startsWith('/en/') ? 'en' : 'tr')
/** '/en/work' → '/work', '/en' → '/' */
export const basePath = (path: string) => (localeFromPath(path) === 'en' ? path.slice(3) || '/' : path)
/** '/work' + en → '/en/work', '/' + en → '/en' */
export const localize = (path: string, locale: Locale) => {
const base = basePath(path)
return locale === 'en' ? (base === '/' ? '/en' : '/en' + base) : base
}Checking only for the exact match and the /en/ prefix is a small but deliberate detail. Had I written a plain startsWith('/en'), adding a page like /entries later would have quietly counted it as English.
05LocLink and the language switch
Writing /work in a RouterLink all over the components would send a reader on an English page back to Turkish on every click. Instead I wrote a small component called LocLink and registered it globally in createSiteApp. It takes the same to value: a string starting with / goes through localize, an object with a path field has only that field translated, and targets that are only a hash or a query pass through untouched. Then it renders an ordinary RouterLink. The header, footer, menu and cards all use it; a crawler following links never switches language at any point.
The language switch does the reverse of the same idea. The TR and EN buttons in SiteHeader do not flip a variable; they go to the current page's address in the other language with router.push, carrying the query and hash along. aria-pressed says which language is active. When the language changes, <html lang> updates through useHead in App.vue, and every page writes hreflang links for tr, en and x-default through useSeo.
Once language stops being a preference and becomes part of the address, the server, the browser and the search engine all see the same thing.
06What I gained, what I gave up
The prerender script now renders every path twice: Turkish as dist/<route>.html, English as dist/en/<route>.html. The sitemap lists both languages together. When I share a link, the other person sees the language I saw, and nothing needs to be remembered for that.
What I gave up is automatic language guessing. Someone arriving with an English browser sees the Turkish root on the first visit and has to press EN. setLocale still writes the preference to localStorage, but the address, not that value, now decides the language. I accepted this on purpose; for a portfolio, predictable addresses felt worth more than saving one click on the first visit.