@shinyaz

Building a Bilingual Tech Blog with Next.js 16, Velite, and MDX

Table of Contents

Overview

Most developer blogs look the same: a Next.js + Markdown setup, maybe Contentlayer, some syntax highlighting, call it a day. When I set out to build shinyaz.com, I wanted something more opinionated — a bilingual blog that treats Japanese and English as first-class citizens, runs entirely as a static site, and stays fast without a CMS or database.

This post walks through the key architectural decisions and what I learned building it. Here's the stack at a glance:

CategoryTechnology
FrameworkNext.js 16 (App Router)
StylingTailwind CSS v4
ContentMDX + Velite
Code HighlightingShiki + rehype-pretty-code
Math Renderingremark-math + rehype-katex
Dark Modenext-themes
PWASerwist
TestingVitest + Playwright
DeploymentVercel

Why Velite for Content

The biggest decision was the content pipeline. Contentlayer was the obvious choice for MDX + Next.js, but it's been effectively unmaintained. Velite offered a similar developer experience — type-safe content collections, build-time processing — while being actively maintained and more flexible.

The pipeline is straightforward: MDX files go through Velite as a prebuild step, which outputs typed JSON that Next.js imports directly.

Output
content/posts/{en,ja}/*.mdx
  → Velite (prebuild)
    → .velite/ (static data)
      → Next.js (App Router)

MDX processing chains three plugins: rehype-slug for heading anchors, remark-math + rehype-katex for math rendering, and rehype-pretty-code (Shiki) for syntax highlighting with dual light/dark themes. What I like about this setup is that everything resolves at build time — no client-side JavaScript for content rendering.

Bilingual Without an i18n Library

I deliberately avoided libraries like next-intl or react-i18next. For a static blog with a fixed set of UI strings, they add complexity without much benefit. Instead, all translations live in a single TypeScript dictionary:

src/lib/i18n.ts
const dictionaries = {
  ja: {
    site: { name: "@shinyaz", description: "..." },
    nav: { home: "ホーム", blog: "ブログ", ... },
    // ...
  },
  en: {
    site: { name: "@shinyaz", description: "..." },
    nav: { home: "Home", blog: "Blog", ... },
    // ...
  },
};

Every route lives under /[locale]/, and content files are split by directory (content/posts/en/, content/posts/ja/). A language switcher in the header lets you toggle between locales on the same page, and bare paths redirect based on the browser's Accept-Language header.

The tradeoff is obvious: this approach doesn't scale to dozens of languages. But for two languages, it's simple, type-safe, and has zero runtime cost.

Visual Design: Monochrome, IBM Plex, and Dark Mode

I wanted the design to get out of the way. Monochrome colors, generous whitespace, and the IBM Plex font family — Sans for body text, Mono for code, and Sans JP for Japanese. The Japanese font is lazy-loaded (preload: false) since it's significantly larger than the Latin variants.

Tailwind CSS v4's CSS-first configuration made theming clean. All color tokens are CSS custom properties that swap between :root and .dark, and next-themes handles the toggle. Code blocks follow the theme automatically via Shiki's dual theme support (github-light / github-dark):

example.ts
function greet(name: string): string {
  return `Hello, ${name}! Welcome to @shinyaz.`;
}
 
console.log(greet("World"));

Content Capabilities

One of the benefits of the MDX + Shiki + KaTeX stack is rich content support out of the box.

Syntax highlighting works across languages with optional filename display:

fibonacci.py
def fibonacci(n: int) -> list[int]:
    """Generate Fibonacci sequence up to n terms."""
    if n <= 0:
        return []
    fib = [0, 1]
    for _ in range(2, n):
        fib.append(fib[-1] + fib[-2])
    return fib[:n]
 
print(fibonacci(10))

LaTeX-style math expressions are fully supported. Inline: E=mc2E = mc^2. Block:

ex2dx=π\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} x=b±b24ac2ax = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}

Each post also gets an auto-generated table of contents with scroll-spy highlighting, and a fully client-side search engine that supports AND matching across titles, descriptions, categories, and tags.

Discoverability: SEO, Feeds, and PWA

For a personal blog, discoverability matters more than you might think. I invested time in several areas:

SEO — JSON-LD structured data (WebSite, BlogPosting, BreadcrumbList), Open Graph / Twitter Card meta tags, proper hreflang / canonical markup for translation pairs, and a dynamic sitemap. These are table stakes, but getting them right for a bilingual site required careful attention to locale handling.

RSS / Atom feeds — Auto-generated per locale (RSS 2.0 and Atom 1.0, latest 20 posts each). I still believe RSS is the best way to follow blogs, so this was non-negotiable.

PWASerwist powers the service worker with build-time precaching and runtime caching strategies. The offline fallback page means the blog degrades gracefully without connectivity.

Security headers — CSP, HSTS (2-year max-age), X-Content-Type-Options, X-Frame-Options, and a restrictive Permissions-Policy are applied to all routes.

Summary

Building a blog from scratch is never the most efficient path, but it's one of the most instructive. A few things I'd highlight for anyone considering a similar stack:

  • Velite is a strong Contentlayer replacement — actively maintained, flexible, and the type-safe content collections work well with App Router.
  • You probably don't need an i18n library — for a small number of languages with static content, a TypeScript dictionary is simpler and has no runtime cost.
  • Tailwind CSS v4's CSS-first approach pairs naturally with theming — CSS custom properties for theme tokens eliminate the need for a separate config file.
  • Invest in build-time processing — the more you resolve at build time (syntax highlighting, math rendering, content queries), the less JavaScript ships to the client.
  • Testing from day one pays off — Vitest for unit/component tests and Playwright for E2E tests catch regressions before they reach production.

Share this post

Shinya Tahara

Shinya Tahara

Solutions Architect @ AWS

I'm a Solutions Architect at AWS, providing technical guidance primarily to financial industry customers. I share learnings about cloud architecture and AI/ML on this site.The views and opinions expressed on this site are my own and do not represent the official positions of my employer.

Related Posts