Right, so you’ve decided to build a site for more than one language. Congratulations, you’ve just leveled up from “building a website” to “managing a small, digital Babel.” It’s a fantastic problem to have, but how you structure the actual files and URLs for this endeavor is your first, and arguably most important, architectural decision. Get it wrong, and you’ll be haunted by redirect loops and SEO ghouls for years. Get it right, and everything else—localization, routing, deployment—becomes significantly easier.

We primarily have two sane ways to structure our content: by directory or by filename. There’s a third, “by subdomain” (fr.yoursite.com), but that brings a whole circus of DNS and cookie issues we’ll ignore for now. Let’s talk about the two you actually have control over.

The Directory Approach: /en/blog/my-post

This is my default recommendation for most projects. The concept is beautifully simple: you prefix every route with the language code. Your site’s structure might look like this:

src/
├── app/
│   ├── [lang]/
│   │   ├── about/
│   │   ├── blog/
│   │   │   ├── [slug]/
│   │   ├── contact/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── ...

The key here is the [lang] directory. This is a dynamic segment (how you create this depends on your framework, like Next.js or Nuxt) that will catch the locale. Your routing then becomes self-documenting: yoursite.com/en/about, yoursite.com/es/about, yoursite.com/fr/blog/mon-article.

Here’s a simplistic example of how you’d handle this in a Next.js App Router page:

// app/[lang]/about/page.tsx
export default async function AboutPage({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  // Fetch your localized content based on the `lang` param
  const content = await getAboutPageContent(lang);

  return (
    <div>
      <h1>{content.title}</h1>
      <p>{content.body}</p>
    </div>
  );
}

Why it’s great: It’s logically separate. All content for a language lives neatly under its own prefix. This makes it dead simple to set up redirects, manage deployments per locale (though I wouldn’t), and for search engines to understand the language targeting. It also means your non-dynamic segments (/about) stay clean and consistent across languages.

The Filename Approach: /blog/my-post.en.md

This approach bakes the locale directly into the filename. It’s very common in static site generators or headless CMS setups where content is file-based. Your structure might look like:

content/
├── blog/
│   ├── my-post.en.md
│   ├── my-post.fr.md
│   ├── another-post.en.md
│   └── another-post.fr.md

Your routing system then parses the filename, strips the locale, and serves the correct one based on the user’s preference or the URL. The URL itself remains “clean”: yoursite.com/blog/my-post. The locale becomes a behind-the-scenes negotiation.

Why you’d use it (and why you might not): The apparent “cleanliness” of the URL is its main attraction. It feels simpler to the end-user. However, this is often more illusion than benefit. You now have to solve the problem of language negotiation and cookie setting on every single request. Should the user’s browser preference win? Should a cookie from a previous visit? It’s a recipe for complexity. It also makes it harder for users to share a language-specific link—how does someone know that the fantastic article they’re reading is the English version to send to their friend in Paris?

The Hybrid Power Move

The most robust approach, used by the big players, is to combine them. Use directory-based routing for the structure, but keep your content files organized by locale. This gives you the logical separation of directories for URLs and the content management simplicity of filenames.

src/
├── app/
│   ├── [lang]/
│   │   ├── blog/
│   │   │   ├── [slug]/
│   │   │   │   └── page.tsx
│   │   │   └── ...
│   │   └── ...
├── content/
│   ├── blog.
│   │   ├── my-post.en.md
│   │   └── my-post.fr.md

Your page.tsx then uses the [lang] from the URL and the [slug] from the URL to look up the specific localized content file.

// app/[lang]/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ lang: string; slug: string }>;
}) {
  const { lang, slug } = await params;
  // Use both the lang and slug to find the exact file
  const post = await getBlogPost(lang, slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

This is the best of both worlds. Your URLs are clear and logical (/fr/blog/my-post), your content files are organized, and you’ve completely sidestepped the messy language negotiation problem. The user’s choice is right there in the URL, unambiguous and shareable. This isn’t just a best practice; it’s a gift to your future self, who will not have to debug why a user in Berlin is suddenly seeing English content because of a cookie set during a visit two years ago. Trust me.