A walkthrough of making a portfolio fully bilingual (EN/ID) — subpath routing with next-intl, per-field content localization in Payload CMS, and the 404 and SEO gotchas I hit along the way.

I wanted my portfolio to do two things at once: serve content in both English and Indonesian, and let me edit that content without redeploying. That meant two kinds of "translation" — the UI strings in the code, and the actual content living in a CMS. Here's how the pieces fit together, and the handful of gotchas that cost me an afternoon.
The project is a small monorepo with two relevant apps:
cms/ — Payload app (Postgres + media on Cloudflare R2) that exposes an admin panel and a REST API.v3/ — the Next.js 15 (App Router) frontend that fetches content from that API.Keeping content in a headless CMS means the site is mostly static/ISR, but I can publish a new project or blog post from the admin without touching code.
For URLs I went with next-intl and subpath routing — /en/... and /id/.... The config is tiny:
export const routing = defineRouting({
locales: ["en", "id"],
defaultLocale: "en",
localePrefix: "always",
})
A middleware handles locale negotiation and redirects / to /en. The whole app/ tree moves under a dynamic app/[locale]/ segment, and the root layout reads the locale to set <html lang> and provide messages.
I started with localePrefix: "as-needed" (so English stays at /projects with no prefix) but switched to "always". The reason is buried in the next section — it makes 404s behave.
UI strings live in messages/en.json and messages/id.json, and components pull them with useTranslations():
const t = useTranslations("nav");
// ...
<Link href="/projects">{t("projects")}</Link>
Payload has first-class localization. You enable it once:
localization: {
locales: ["en", "id"],
defaultLocale: "en",
fallback: true,
}
then mark individual fields localized: true (title, description, rich-text body, etc.). Non-localized fields like slug, dates, and relationships stay shared across locales — which is exactly what you want: one slug, one set of relations, translated text.
On the frontend, every fetch just passes the active locale:
const res = await fetch(`${CMS_URL}/api${path}&locale=${locale}`, {
next: { revalidate: 60 },
})
With fallback: true, a missing Indonesian translation falls back to English, so the site is never blank while I'm still translating.
The gotcha: fallback only applies on read. When you write a locale, required localized fields must be present for that locale too. My seed script created each doc in English, then re-saved it with locale: "id" for the translated fields — and it blew up on a required role field that I hadn't included in the Indonesian payload. Fallback doesn't save you on writes; set required localized fields for every locale you touch.
Here's the afternoon-eater. In dark mode, my 404 page had a pure-black background that ignored the theme toggle. The culprit: when there's no custom not-found, Next.js injects its own page with this style:
@media (prefers-color-sceme: dark) {
body { background: #000 }
}It keys off the OS preference, not my theme class — so toggling the site to light did nothing. The fix is simply to provide a custom not-found.tsx; once you do, Next stops injecting that default.
But with the root layout living under app/[locale]/, an unmatched path like /random gets treated as [locale] = "random", fails the locale check in the layout, and the notFound() bubbles to the root boundary — which has no themed layout, so you're back to Next's default 404. Switching to localePrefix: "always" plus a catch-all (app/[locale]/[...rest]/page.tsx that calls notFound()) means every unmatched path resolves under a valid locale and renders the themed, localized 404. That's why I dropped "as-needed."
Two locales means telling search engines how the pages relate. Each page emits canonical + hreflang alternates:
export function alternates(locale: string, path = "") {
const languages: Record<string, string> = {};
for (const l of routing.locales) languages[l] = `${SITE_URL}/${l}${path}`;
languages["x-default"] = `${SITE_URL}/${routing.defaultLocale}${path}`;
return { canonical: `${SITE_URL}/${locale}${path}`, languages };
}The sitemap lists each page once with its language alternates, and metadataBase makes Open Graph URLs absolute. One more thing that's easy to forget while a site is still on a staging domain: gate indexing behind an env flag (robots: { index: false } + Disallow: / in robots.txt) so Google doesn't index the temporary URL — then flip it at launch.
localePrefix: "always" made routing and 404s predictable when the root layout lives under [locale].fallback is read-only. Required localized fields still need a value per locale on write.not-found — the default one ignores your theme.noindex.None of these are hard individually; the work is in how they interact. Once the layers were clean, adding a third language would just be one more entry in the locales array and one more column of translations.