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:
| Category | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Styling | Tailwind CSS v4 |
| Content | MDX + Velite |
| Code Highlighting | Shiki + rehype-pretty-code |
| Math Rendering | remark-math + rehype-katex |
| Dark Mode | next-themes |
| PWA | Serwist |
| Testing | Vitest + Playwright |
| Deployment | Vercel |
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.
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:
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):
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:
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: . Block:
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.
PWA — Serwist 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.
