Internationalization often creates a frontend trade-off.
You can bundle every translation into JavaScript. That makes switching languages fast, but it increases the initial payload. Or you can fetch translation files separately, which adds network requests and client-side state.
Laravel and Inertia offer a cleaner option.
Keep translations in Laravel’s lang files. Set the locale on the server. Share only the active language through Inertia props. Vue renders the page without carrying every supported language in the bundle.
This approach works well for onboarding flows, account dashboards, and public application pages. It also preserves real URLs and server-rendered HTML for search engines.
Laravel remains the source of truth. Vue stays focused on interaction.
Why Inertia changes the i18n conversation
A conventional SPA often imports translation dictionaries into its JavaScript bundle:
import en from './locales/en.json'
import fr from './locales/fr.json'
import de from './locales/de.json'
That works, but every visitor may download all three languages. The cost grows with every new locale.
Inertia already sends page data from Laravel to Vue. Translation data can use the same path.
The server knows the current locale before rendering the page. It can resolve the correct strings from Laravel’s translation files and include them in the initial Inertia page props.
The browser receives:
{
"locale": "fr",
"translations": {
"nav": {
"dashboard": "Tableau de bord",
"settings": "Paramètres"
}
}
}
It does not receive the complete English, French, and German dictionaries.
That is the important distinction. You are not building a separate translation API or loading an entire localization library by default. You are using the server boundary that Inertia already provides.
Laravel’s translation files and locale rules
Laravel supports PHP translation files inside locale directories:
lang/
├── en/
│ └── ui.php
├── fr/
│ └── ui.php
└── es/
└── ui.php
You can also use JSON files such as lang/en.json. Laravel’s localization documentation covers both approaches.
For an application interface, keyed PHP files are usually easier to organize:
<?php
// lang/en/ui.php
return [
'nav' => [
'dashboard' => 'Dashboard',
'settings' => 'Settings',
],
'onboarding' => [
'welcome' => 'Welcome, :name',
'steps' => [
'one' => ':count step remaining',
'other' => ':count steps remaining',
],
],
];
Set a default and fallback locale in config/app.php:
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
The fallback matters when a locale does not contain a key. Laravel can resolve the fallback value before the translation reaches Vue.

Set the locale from the URL first
Locale-prefixed URLs are a strong default for public applications:
/en/onboarding
/fr/onboarding
/es/onboarding
They are visible, shareable, cacheable, and useful for SEO.
Laravel route groups make the prefix easy to apply. The routing documentation covers route groups, prefixes, and parameter constraints.
use App\Http\Controllers\OnboardingController;
use Illuminate\Support\Facades\Route;
Route::prefix('{locale}')
->whereIn('locale', ['en', 'fr', 'es'])
->middleware('set-locale')
->group(function () {
Route::get('/onboarding', [OnboardingController::class, 'show'])
->name('onboarding');
});
The middleware should prefer the route locale. A session value can provide a fallback for users who have selected a language but entered an unprefixed URL.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
public function handle(
Request $request,
Closure $next
): Response {
$supported = ['en', 'fr', 'es'];
$locale = $request->route('locale')
?? $request->session()->get('locale')
?? config('app.locale');
if (! in_array($locale, $supported, true)) {
$locale = config('app.fallback_locale');
}
App::setLocale($locale);
return $next($request);
}
}
For canonical public URLs, keep the locale in the route. Use session-based switching for authenticated areas where SEO is not important, or as a convenience when redirecting users to their preferred language.
Share only what the current page needs
HandleInertiaRequests is the right place for shared locale data. It runs for Inertia requests and keeps the setup in one location.
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Lang;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'locale' => fn () => App::currentLocale(),
'translations' => fn () => [
'ui' => Lang::get('ui'),
],
]);
}
}
This example shares one interface namespace. That is usually safer than exposing every file under lang.
For larger applications, split translations by purpose:
'translations' => fn () => [
'ui' => Lang::get('ui'),
'validation' => Lang::get('validation'),
],
Page-specific translations can be returned from the controller instead. For example, an analytics page might receive analytics strings while onboarding receives onboarding strings.
The goal is simple: send the active locale, shared interface strings, and nothing else.
Inertia 3.x deferred props can help with expensive dashboard data. Do not defer translations required for the first render. A deferred translation prop creates an avoidable untranslated state and can cause layout changes.
A small Vue translation composable
You do not need a large client-side localization layer for basic UI strings.
A small composable can read translations from Inertia’s reactive page props:
// resources/js/composables/useTranslation.js
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
export function useTranslation() {
const page = usePage()
const locale = computed(() => page.props.locale ?? 'en')
const translations = computed(() => page.props.translations?.ui ?? {})
const get = (key) => {
return key.split('.').reduce(
(value, segment) => value?.[segment],
translations.value
) ?? key
}
const t = (key, replacements = {}) => {
let value = get(key)
for (const [name, replacement] of Object.entries(replacements)) {
value = value.replace(`:${name}`, String(replacement))
}
return value
}
const choice = (key, count, replacements = {}) => {
const forms = get(key)
if (typeof forms === 'string') {
return forms.replace(':count', String(count))
}
const category = new Intl.PluralRules(locale.value).select(count)
const value = forms?.[category] ?? forms?.other ?? forms?.one ?? key
return value
.replace(':count', String(count))
.replace(/:([a-zA-Z_]+)/g, (_, name) => {
return replacements[name] ?? `:${name}`
})
}
return {
locale,
t,
choice,
}
}
Use it in a page component:
<script setup>
import { useTranslation } from '@/composables/useTranslation'
const { t, choice } = useTranslation()
</script>
<template>
<h1>{{ t('onboarding.welcome', { name: 'Sam' }) }}</h1>
<p>
{{ choice('onboarding.steps', 2) }}
</p>
</template>
Intl.PluralRules handles locale-aware categories better than splitting a string on | in the browser. For complex grammatical rules, use Laravel’s trans_choice on the server or a dedicated library such as Vue I18n.
The same principle applies to formatting. Translation strings should not format currencies, dates, or numbers manually.
const amount = new Intl.NumberFormat(locale.value, {
style: 'currency',
currency: 'EUR',
}).format(1299.5)
const date = new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
}).format(new Date())
The active locale controls both the words and the presentation.
Switch languages without a full browser reload
A language selector can visit the equivalent locale-prefixed route through Inertia:
<script setup>
import { router } from '@inertiajs/vue3'
const switchLocale = (locale) => {
router.visit(route('onboarding', { locale }), {
preserveScroll: true,
})
}
</script>
<template>
<select @change="switchLocale($event.target.value)">
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
</select>
</template>
The browser does not perform a traditional full page reload. Inertia requests the new page, Laravel applies SetLocale, and HandleInertiaRequests returns the new translation props.
Vue re-renders the existing application shell with the new strings.
The URL changes from /en/onboarding to /fr/onboarding, which keeps the language explicit. Preserve the user’s route, query parameters, and relevant form state when building the equivalent URL.
SEO: locale routes plus SSR
A client-only language switcher does not create indexable language pages. Search engines need distinct URLs and meaningful HTML.
Use locale-prefixed routes for public pages. Then enable Inertia SSR so /en/onboarding and /fr/onboarding return translated HTML before JavaScript runs.
Inertia’s SSR documentation notes that SSR pre-renders JavaScript pages on the server. It also improves crawlability because visitors and crawlers receive fully rendered HTML.
Your Vue page can use Inertia’s Head component:
<script setup>
import { Head } from '@inertiajs/vue3'
import { useTranslation } from '@/composables/useTranslation'
const { t } = useTranslation()
</script>
<template>
<Head :title="t('meta.onboarding_title')">
<meta
name="description"
:content="t('meta.onboarding_description')"
/>
<link
rel="alternate"
hreflang="en"
:href="alternates.en"
/>
<link
rel="alternate"
hreflang="fr"
:href="alternates.fr"
/>
<link
rel="alternate"
hreflang="es"
:href="alternates.es"
/>
</Head>
</template>
Generate alternates on the server where possible. Server-side route generation avoids incorrect paths when pages contain slugs or nested parameters.
SSR requires the same locale setup in the client and SSR entry. If the browser initializes with English while the server rendered French, hydration can produce mismatches.
Build both bundles and run the Inertia SSR server in production. Check SSR failures during deployment because Inertia can fall back to client-side rendering when SSR fails.
Localized API responses still belong on the server
Some screens need more than Inertia props. A dashboard may fetch activity, recommendations, or catalog data from an API.
If you build a REST API with PHP, Laravel can return localized content using the same locale rules:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/{locale}/api/catalog', function (
Request $request,
string $locale
) {
abort_unless(in_array($locale, ['en', 'fr', 'es'], true), 404);
app()->setLocale($locale);
return response()->json([
'title' => __('catalog.title'),
'items' => [
[
'name' => __('catalog.items.starter'),
'description' => __('catalog.items.starter_description'),
],
],
]);
});
For stateless APIs, use a locale prefix or a validated Accept-Language header. Do not depend on a session that may not exist.
The Vue client can consume the endpoint without duplicating translation files. Laravel remains responsible for server-generated content, while the frontend handles loading and interaction.
Cache and SSR caveats
Locale changes affect more than visible text.
Cache keys must include the locale. A cached /en/dashboard response must never be served for /fr/dashboard. Locale-prefixed URLs make this easier because the path naturally separates cache entries.
If you use session-based locales, vary cache behavior by session or avoid caching personalized responses.
SSR processes must also receive request-specific props. Never store the active locale in a module-level variable inside the SSR bundle. Multiple requests may render in the same Node process.
Finally, test every supported locale with:
- direct URL requests,
- Inertia navigation,
- SSR output,
- fallback strings,
- pluralization,
- dates and currencies,
- validation messages,
- cache headers,
-
hreflanglinks.
A multilingual Inertia application does not need a translation payload for every language. It needs a clear locale boundary.
Let Laravel resolve the language. Let Inertia carry the active strings. Let Vue format and display them. Keep the bundle focused, the URLs meaningful, and the server-rendered HTML ready for every audience.
When your application grows, the same pattern scales with it: shared props for common UI, page props for specialized screens, deferred props for expensive data, and Laravel’s ecosystem for the rest.