Today was an i18n day. Wired up Lingui plus locale-aware routing across all three apps. The frontend, website, and backend now all speak English and Portuguese, with the plumbing (hreflang, sitemap, Accept-Language middleware, App::setLocale()) to match.
Worth doing this early, with two strings in the whole app, rather than six months in when every component has a hardcoded English label and retrofitting is a nightmare. The Turkey test matters, and it's the kind of thing that costs you almost nothing if you bake it in from the start.
I went with Lingui for the macro DX. You write <Trans>Hello {name}</Trans> in JSX, and that's it, there's nothing else to do. The library figures it out, it extracts/compiles the list of strings and their IDs based on that, automatically. First time using Lingui, but I already love it.
Internationalization brings with it a much wider market. Working on Pancake, I saw just how much non-English-speaking countries are neglected when it comes to modern software. Think about it: How many fancy startups have you seen that just do not care? The talk of niching down to find a target market also goes for countries and languages; there are a lot of underserved countries out there.
Lingui 6 pre-release
Lingui 5 is the current stable release, but it has issues with the ESM-only nature of this project. The v6 pre-release is ESM-compatible and works fine, so I went with that.
Normally I'd be cautious about running a pre-release in the foundation of a project, but:
- Lingui 6 will be out well before Gingerbread launches
- Upgrading from 5 to 6 later is its own breaking-change mess
- This one specifically fixes a bug that already affects us
Better to live on the bleeding edge now and migrate away from pre-release as v6 stabilizes, than to start on 5 and end up rewriting later.
Vitest needs its own macro transform
Next uses @lingui/swc-plugin for the <Trans> macro transformation. Vitest doesn't use Next, so it doesn't get that plugin. As a result, Vitest renders a <Trans> and blows up, because the macro never got transformed into actual code.
Ended up having to add a Babel pipeline to Vitest with @lingui/babel-plugin-lingui-macro. This does mean we now have two macro pipelines running in this repo: SWC for prod, Babel for tests. It's a bit ugly, and I tried to unify them with unplugin-swc first, but no dice. It's just a bit of extra complexity.
BCP-47 vs ICU
Symfony's Request::getPreferredLanguage() normalises pt-PT (BCP-47, the web standard) to pt_PT (the ICU underscore form) on the way out. Laravel uses ICU internally.
If you let that through, you end up with pt_PT in the database, pt-PT in the frontend, pt_PT in API responses, and every boundary is a conversion. That's the kind of small inconsistency that ships to production and never gets cleaned up.
Picked BCP-47 end-to-end. The middleware normalizes Symfony's output back to hyphens before App::setLocale().
Website: default locale at root
/ and /blog are English. /pt and /pt/blog are Portuguese. Most marketing sites work this way.
Next 16's App Router models it like this:
app/
├── (en)/
│ ├── layout.tsx <html lang="en">
│ └── blog/...
└── pt/
├── layout.tsx <html lang="pt-PT">
└── blog/...
Every page file ends up as a thin shim. (en)/blog/page.tsx is three lines and imports a shared <BlogIndex locale="en" /> component. The duplication is cosmetic.
Laravel Actions
I use Laravel Actions for everything, that's how this project is starting. I first came across this pattern while working at Prompt, and I'll never go back. Instead of having fat controllers with multiple methods, or scattering logic across models and services, everything lives in these single-purpose "action" classes. Each action has a handle() method that does one thing.
// app/Actions/GetBootContext.php
final class GetBootContext {
use AsAction;
public function handle(): JsonResponse { /* ... */ }
}
// routes/web.php
Route::get('/boot', GetBootContext::class);
The plan is for the frontend to hit /boot on startup and get everything it needs to render the initial UI: app version, current user (if logged in), setup state, feature flags, whatever. For now, it returns three fields just to prove the concept, but it can grow from there.