While adding English support to this site, I learned that translation is the easy part. The hard part was three decisions: what the URLs should look like, who picks the language, and what happens to content living in the database. There's also one bug I wouldn't wish on anyone — I saved it for last.
# the_url_decision_comes_first
On a bilingual site, the first decision isn't the i18n library — it's URL design. My criteria were clear: the existing Turkish URLs (/blog, /hakkimda) must not change by a single character — Google's index and every shared link must keep working. And English shouldn't be a hidden translation layer, but real pages with their own addresses so they get indexed separately.
next-intl's localePrefix: "as-needed" mode does exactly that: the default locale (tr) stays unprefixed at the root, English lives under /en/.... With reciprocal hreflang tags on every page, Google sees the two versions as related but separate pages.
# language_detection_doesnt_work_out_of_the_box
The behavior I wanted fits in one sentence: Turkish browsers see Turkish, everyone else sees English. next-intl's built-in negotiation stumbles exactly here: it matches Accept-Language only against the configured locales (tr, en); a German browser matches neither and silently falls back to defaultLocale — Turkish. Showing a Turkish page to a visitor from Berlin is the exact opposite of "everyone else sees English."
The fix was turning the built-in detection off (localeDetection: false) and writing my own three-line logic in the middleware:
The order matters: the URL always wins (shared links stay consistent), then the cookie holding the user's explicit choice, and only then the browser language. The tr | en toggle in the nav writes the cookie and performs a full-page navigation — switching via the client router creates a race between the cookie write and the request, and the middleware bounces you right back to the old language. I learned that one the hard way too.
# content_translation_is_not_ui_translation
Button and menu strings live in messages/tr.json and en.json; standard stuff. But most of the text on this site comes from the database: profile, services, projects, posts. Those need schema, not JSON files: I added an *En column next to every translatable column and a ?lang=en parameter on the API.
The critical detail is per-field fallback: when lang=en is requested, each field first tries its English value and falls back to Turkish if it's empty. That lets me fill translations incrementally; a missing field doesn't break the page — it just shows up in Turkish. The contentLanguage field I added to the post DTO puts an honest note on top of any post that's still Turkish in the English view: "this post is available in Turkish only."
# the_nested_html_trap
And the bug I promised. Setup done, everything working — but the console shows a hydration warning on every full page load: React can't reconcile the server HTML and re-renders the whole page on the client. Since the page still works, it's easy to ignore — and because it silently eats performance, it's a perfect trap.
The cause: the file tree had both app/layout.tsx (the old root) and app/[locale]/layout.tsx, and both rendered <html>. In Next.js, layouts nest unconditionally — there is no such thing as "the outer one is just a fallback." You end up with <html> inside <html>; the browser silently fixes that while parsing, and React then compares its expectations against the fixed-up DOM and panics.
The fix is deleting the root app/layout.tsx entirely: app/[locale]/layout.tsx becomes the one true root layout. A tiny catch-all moves the root 404 handling into the locale tree:
// app/[locale]/[...rest]/page.tsx
import { notFound } from "next/navigation";
// every unknown url lands on the translated 404
export default function CatchAll(): never {
notFound();
}
# summary
Going bilingual, in order: decide the URL design first (keep the default locale unprefixed), write language detection with your own rule (built-in negotiation says "unknown language → default locale", which may be the opposite of what you want), set up per-field fallback for database content, and if you use app/[locale], don't leave a second root layout above it. Every item on this list has been tested on this very site — not always on purpose.