seven / writing / 2026 / building-this-site-part-25 min read
§ 02 · 2026-05-08 · Craft · Systems

The Journey of Building My Website (part 2): App Router, MDX, and the marquee that nearly broke me

Next.js 16's App Router isn't what I expected. The MDX blog design decisions. And a CSS animation that took an embarrassingly long time to get right.

Once the design was settled, I started writing code.

This post is technical. Not a tutorial — more a record of which decisions I made, why, and what went wrong along the way.


App Router: why I always reach for it

Most of my web projects use Next.js with the App Router. There's no particularly principled reason — when I first started learning Next.js, almost every tutorial I found was already using App Router, and the habit stuck.

The upside of learning it early: the mental model is just second nature now. By default, everything is a Server Component.

Simple in principle, but in practice I kept making the same mistake: trying to use useState or useEffect in a Server Component, getting a TypeScript error, adding "use client", and losing all the benefits of running on the server in the first place.

The correct mental model is: data happens on the server, interaction happens on the client. Server Components fetch data and pass it as props to Client Components. Client Components handle user interaction. Once that line is clear, the architecture follows naturally.

A representative example from this site: the "Recent Posts" section on the home page. Post data needs to be read from MDX files — I/O, server side. But the list is just static markup; no interactivity required. So HomePage is an async Server Component that reads the file system, passing results as props to a synchronous component for rendering.

// Server Component — can await directly
export default async function HomePage({ params }: PageProps) {
  const posts = await getAllPosts(locale);
  return <Home recentPosts={posts.slice(0, 4)} />;
}
 
// Client Component — just renders
function Home({ recentPosts }: { recentPosts: Post[] }) {
  const t = useTranslations("home");
  // ...
}

This pattern appears throughout the site.


i18n: configuring next-intl

The bilingual route structure is /[locale]/..., where locale is either en or zh-TW.

next-intl gives you two ways to read translations: useTranslations for Client Components, and the async getTranslations for Server Components.

One common requirement is using translated text inside generateMetadata for page titles — which is server-only:

export async function generateMetadata({ params }) {
  const t = await getTranslations({ locale, namespace: "metadata" });
  return { title: t("homeTitle") };
}

Worth knowing about: t.raw(), which returns the raw JSON value rather than a string. Array data — tech stack items, blog post lists — lives in the JSON and gets pulled out with t.raw() for iteration.


The MDX blog: design decisions

Blog posts are MDX files in content/blog/en/ and content/blog/zh-TW/.

I chose MDX over a database or CMS for one reason: I wanted posts to be version-controlled. Each post is a file with a git history. I can see what changed when.

Frontmatter schema:

---
title: "Post title"
date: "2026-05-08"
excerpt: "A brief summary."
tags: ["Craft", "Systems"]
---

lib/posts.ts handles all the reading. This file is marked import "server-only" to ensure it never gets bundled into the client.

Read time calculation

I started with hardcoded readTime in frontmatter — figured manual was more accurate. In practice I forgot to update it every time, and ended up with a short post showing 22 minutes reading time. A friend asked what on earth I'd written that took that long.

Auto-calculation introduced a different bug:

// Works for English. Breaks for Chinese.
Math.round(content.split(/\s+/).length / 200)

Chinese text doesn't use spaces as word delimiters. A full Chinese post split on whitespace might yield only a handful of tokens, giving a reading time of 1 minute for everything.

Fix: count separately.

function calcReadTime(content: string): number {
  const cjk = (content.match(/[一-鿿㐀-䶿豈-﫿]/g) ?? []).length;
  const western = content.trim().split(/\s+/)
    .filter(w => /[a-zA-Z0-9]/.test(w)).length;
  return Math.max(1, Math.round(cjk / 500 + western / 200));
}

CJK characters at 500 per minute, western words at 200. Not scientifically precise — calibrated by reading a few posts against a timer.


Drawing an SVG for every project

Each project on the projects page needed an abstract illustration that somehow related to the project. I decided not to use Figma or Illustrator — I wrote the SVGs directly in JSX.

Five projects, five illustrations:

  • TronClass API: notebook ruled lines, referencing API documentation
  • TimeNest: three-axis accelerometer diagram, representing Core Motion sensing
  • OLLM: chat bubbles with a key icon (the BYOK concept)
  • VALKI: type specimen grid, representing Flutter's layout system
  • MenuGo: QR code structure

These SVGs later got refactored into a shared component, components/project-svgs.tsx, imported by both the home page and the projects page to stay in sync.


The marquee

Both the home page and the services page have a horizontally scrolling marquee: "Open for collaboration."

Looks simple. Took an unreasonable amount of time.

Version one:

.tape .track {
  animation: tape 30s linear infinite;
}
@keyframes tape {
  to { transform: translateX(-100%); }
}

Problem: after the text scrolls off, there's a gap before it reappears. translateX(-100%) moves the entire track out of view, with nothing waiting to take its place.

Version two: duplicate the content, making the total width 200%, then animate only -50% — exactly one copy's worth. Visually seamless.

export function Tape({ text }: { text: string }) {
  const ITEMS = Array.from({ length: 8 }, (_, i) => i);
  return (
    <div className="tape" aria-hidden="true">
      <div className="track">
        <div className="track-group">
          {ITEMS.map(i => <span key={i}>{text}</span>)}
        </div>
        <div className="track-group" aria-hidden="true">
          {ITEMS.map(i => <span key={i}>{text}</span>)}
        </div>
      </div>
    </div>
  );
}

The critical detail: flex-shrink: 0 on both groups. Without it, flex layout will compress them to fit available space, making the two groups unequal widths, and the -50% translation won't land in the right place.


Static generation vs. dynamic rendering

Almost everything on this site is statically generated at build time.

The only exception: the contact form, which uses a Server Action to call the Resend API at request time. But the form is just a Client Component action — it doesn't affect the static nature of the page itself.

The benefit becomes obvious after deploying to Vercel: first loads are fast because most content is pre-rendered HTML served from the CDN cache, not computed on demand.


Next: the contact form Server Action, the colophon page, and some thoughts on what it actually means to put something public online.

Back to the blog