An English File Does Not Make a Bilingual Route

Part 3 of The Portfolio Is an Interface.

I added English post routes to my portfolio and used a condition that looked impossible to misunderstand:

post.langs.includes("en");

If a post has English, generate /posts/<slug>/en. What could possibly go wrong?

Three of the six posts in that first corpus answered the question.

They were English-only posts. My base route already fell back to the English file when no Korean file existed, so each of those posts appeared at both /posts/<slug> and /posts/<slug>/en. Same body, two URLs, and each page declared itself canonical.

I had not built a bilingual route. I had built a duplicate-content machine with a language switch that sometimes linked a post to itself. (Internationalization, but make it circular.)

The filename rule looked simpler than the content model

The repository stores posts with one filename-derived slug:

  • <slug>.md is the Korean base file.
  • <slug>.en.md is the English translation.

When both files exist, the routing is straightforward. The base URL serves Korean, /en serves English, and each page may link to the other.

Single-language posts make the model more interesting.

A Korean-only slug has one base file and no English route. An English-only slug has one .en.md file, but the base route still needs to render it; otherwise the post list would contain a slug that crashes when opened. So getPost() falls back to whichever language file exists.

That fallback is useful. It is also the reason “has an English file” cannot mean “needs a second English URL.”

I had two different questions wearing the same boolean:

  • Can this slug render English content?
  • Does this slug represent two language variants?

For an English-only post, the first answer is yes and the second is no. langs.includes("en") can answer only the first question.

Round 1: the link lied before the route did

The first visible symptom was the language switch.

If a post had an English file, the UI offered an English link. For an English-only post already rendered at the base URL, that link pointed to the same body under /en while promising a translation.

The first repair introduced a real bilingual predicate:

export function hasBothLangs(post: Post): boolean {
  return post.langs.includes("ko") && post.langs.includes("en");
}

export function isBilingual(slug: string): boolean {
  const post = listPosts().find((entry) => entry.slug === slug);
  return post !== undefined && hasBothLangs(post);
}

The cross-language link now appears only when both files exist.

That fixed the lie in the interface. It did not yet fix the duplicate route.

This is a small sequencing detail with a large lesson: a UI symptom may be only one reader of a wrong concept. Once I named hasBothLangs, I had to find every other place that had quietly invented its own definition of “translated.”

Round 2: one wrong predicate had three readers

The English route's generateStaticParams() still generated a page for every slug carrying en. The sitemap used the same condition. The cross-link had already moved to hasBothLangs.

That meant one conceptual rule was now expressed in three surfaces:

  • route generation,
  • sitemap generation,
  • language navigation.

The fix made the route and sitemap call the same predicate (the excerpts below omit unrelated metadata fields):

export function generateStaticParams() {
  return listPosts()
    .filter(hasBothLangs)
    .map((post) => ({ slug: post.slug }));
}
const postEntries = listPosts().flatMap((post) => [
  { url: `/posts/${post.slug}` },
  ...(hasBothLangs(post) ? [{ url: `/posts/${post.slug}/en` }] : []),
]);

In the affected build, the generated route count dropped from 23 to 20 and the sitemap's post URLs dropped from 10 to 7. Those three removed routes were not lost translations. They were duplicate addresses for English-only bodies already served at the base route.

This is why counting output can be useful when the count has a model behind it. “Three fewer routes” is not automatically good. “One route per English-only slug and two per bilingual slug” is the invariant that explains the number.

Round 3: the redirect must be able to retire

The duplicate /en URLs had already been live briefly. Deleting the generated routes would turn them into 404s for anyone holding an old or guessed link.

So I added redirects from every English-only /en URL to its base route.

The obvious implementation was a hardcoded list of the affected slugs. I avoided that by deriving the list from content/posts/ during the build:

function enOnlySlugs(): string[] {
  const files = fs.readdirSync(POSTS_DIR);
  return files
    .filter((file) => file.endsWith(".en.md"))
    .map((file) => file.slice(0, -".en.md".length))
    .filter((slug) => !files.includes(`${slug}.md`));
}

If a Korean file is added later, that slug is no longer English-only. It drops out of the redirect list and /posts/<slug>/en becomes a real translation route.

My first redirect choice was 308 Permanent Redirect. That was wrong for the same reason a hardcoded list was wrong.

A browser may cache a permanent redirect indefinitely. If the post later gains a Korean translation, the application can restore the /en route while that browser keeps jumping to the base page. The old redirect would shadow the new route from the reader's point of view.

The current configuration uses a temporary 307:

return enOnlySlugs().map((slug) => ({
  source: `/posts/${slug}/en`,
  destination: `/posts/${slug}`,
  permanent: false,
}));

Temporary is not indecision here. It is a lifecycle requirement. The redirect exists only while one language file is absent.

Round 4: “single definition” still had a second implementation

At this point I had hasBothLangs() in src/lib/posts.ts, and I described it as the single definition of a bilingual slug.

Then review found a gap.

next.config.ts did not reuse the helper; it re-derived the English-only set from filenames. Nothing proved that its definition stayed equivalent to hasBothLangs().

Worse, the first fix had no regression coverage for the whole relationship. Restoring the old route predicate and sitemap predicate left every existing test green.

The added test compares the redirect config against the real post corpus:

const redirected = (await nextConfig.redirects())
  .map((rule) => rule.source.replace(/^\/posts\/(.+)\/en$/, "$1"))
  .sort();

const enOnly = listPosts()
  .filter((post) => !hasBothLangs(post) && post.langs.includes("en"))
  .map((post) => post.slug)
  .sort();

assert.ok(enOnly.length > 0, "no en-only slugs — fixture assumption is stale");
assert.deepEqual(redirected, enOnly);

The non-empty assertion matters. Without at least one English-only slug, equality between two empty arrays would prove nothing about the case that caused the bug.

This test does not prove every URL behaves correctly in a deployed browser. It proves one precise relationship: the configuration redirects exactly the slugs the application model classifies as English-only.

The routes, sitemap, and cross-links still need their own readers.

A bilingual route is a state transition

The final model is small:

  • Korean-only: base route only.
  • English-only: base route serves the English fallback; guessed /en temporarily redirects to base.
  • Korean and English: base route plus a real /en route; both pages cross-link.

Adding one file changes the third state. That state change must update route generation, navigation, sitemap output, and redirect behavior together.

This is why I no longer think of <slug>.en.md as merely another file suffix. It is one input to a routing state machine. The other input is whether <slug>.md exists.

The lesson I am keeping

When a feature says “multilingual,” do not ask only which languages a record contains. Ask how many distinct variants exist and which URL already owns each one.

Then name predicates after the question they actually answer.

hasEnglish is useful for choosing a body. hasBothLangs is useful for creating a second route. They are not synonyms, even when both return true for your first happy-path fixture.

Finally, test the transition, not only the steady state. A redirect that is correct before a translation arrives must get out of the way after it arrives. A permanent cache and a hardcoded list cannot do that gracefully.

My duplicate URLs started with one readable line of code. The fix was not a more clever line. It was a better question.