Skip to main content
Next.jsTypeScriptTailwindMDX

How I Built This Website: Next.js, MDX and a Bilingual Blog with Static Export

The architecture behind blasruizraul.com: Next.js 14 with App Router, Tailwind CSS, an MDX blog, ES/EN internationalization with two root layouts and static deployment on Netlify.

8 min read

This website is, besides my portfolio, one more project: architecture decisions, trade-offs and the occasional dead end. In this article I document how it is built, in case it serves as a reference for building your own.

The stack

  • Next.js 14 (App Router) with TypeScript as the foundation.
  • Tailwind CSS for styling, with a small custom design system (glass cards, neon accents, animations that respect prefers-reduced-motion).
  • MDX for the blog, rendered with next-mdx-remote.
  • Vitest + Testing Library for tests.
  • Static export (output: 'export') deployed on Netlify.

The most important decision is the last one: the website has no server. Every build generates pure static HTML, which means free hosting, minimal response times and a practically nonexistent attack surface. In exchange, you give up everything dynamic at runtime — and that constraint shapes the rest of the architecture.

The blog: MDX + frontmatter

Each article is an .mdx file in content/blog/ with its frontmatter (title, date, excerpt, tags, language). A small library (lib/posts.ts) reads the directory, validates the metadata and exposes the posts sorted by date.

export interface PostMeta {
  slug: string
  title: string
  date: string
  excerpt: string
  tags: string[]
  readingTime: number
  lang: 'es' | 'en'
}

Rendering uses next-mdx-remote/rsc with remark-gfm (tables), rehype-slug (heading anchors) and rehype-pretty-code (syntax highlighting). On top of that, custom components injected into the MDX: a Callout for notes and warnings, and a CodeBlock with a copy button.

Reading time is computed automatically from the word count when not provided in the frontmatter — a small detail, but exactly the kind of thing a content file should not have to maintain by hand.

Internationalization without a server

The most technically interesting part. I wanted the whole website in Spanish and English with two requirements: Spanish URLs must not change (the root stays /), and each blog must show only its own language.

The problem: with output: 'export', Next.js native i18n does not exist. The solution: two root layouts via route groups:

app/
  (es)/
    layout.tsx        ← <html lang="es">
    page.tsx          ← /
    blog/...          ← /blog
  (en)/
    layout.tsx        ← <html lang="en">
    en/
      page.tsx        ← /en
      blog/...        ← /en/blog

Each layout declares its <html lang>, and all the site chrome (nav, footer, skip link) is shared through a SiteShell component parameterized by language.

All copy lives in typed dictionaries: es.ts defines the shape with export type Dictionary = typeof es, and en.ts is annotated with that type — if the translation is missing a key, the build fails at compile time. There is no i18n library: they are plain TypeScript objects that server components pass down to client components as props.

To pair articles across languages there is a slug-map.ts acting as the single source of truth, guarded by a test that reads the actual frontmatter in content/blog and fails if a new post lacks its counterpart. The language switcher in the nav uses that map to take you to the equivalent article, not to the home page.

Details that make the difference

  • Scroll-spy in the nav with IntersectionObserver: the visible section gets highlighted without any library.
  • Animated counters in the stats, with custom easing, disabled when the user prefers reduced motion.
  • Table of contents generated from the MDX itself, highlighting the active section while you read.
  • hreflang and canonical on every paired page, so Google understands the relationship between versions.
  • Accessibility: skip link, aria-label on everything interactive, visible focus, WCAG AA as the target.

The process: developed with AI, reviewed by humans

Consistent with what I explain in my article about AI: this website is built with Claude Code as copilot — the i18n architecture, the dictionary refactor and the tests came out of joint working sessions. My role: deciding, reviewing and cutting when a proposal did not fit. The result is code I understand line by line, which is exactly the criterion I stand for.

Conclusion

A personal website is the perfect project for making architecture decisions without pressure: there is room here for a radical static export, a hand-rolled i18n system and an MDX blog with tests. All the code is open source — and if anything you have read resonates with you, the blog will keep documenting the evolution.