Skip to content

post.md

Oct 23, 2026 · 3 min read
Animated ASCII art of a Nacra 17 catamaran sailing across the ocean at sunset

How this site is built

A short tour of the architecture behind cmkrw.com, from the terminal layout to Markdown, charts, and error reporting.

  • nextjs
  • design
Contents

This site is small on purpose: one page, one blog, and as little JavaScript as I can get away with. Here is how the pieces fit together, in case you want to build something similar.

The terminal is the layout

The site is laid out like a terminal app. A tab bar runs along the top, a status line sits at the bottom, and everything in between is a pane: a bordered box with its title set into the top edge. One small component, Pane, draws all of them.

The blog layout: a post list on the left and the open post on the rightThe blog as a master–detail view: the list stays put while the post changes.

Navigation follows the same idea. On the blog, the post list stays on the left while the post opens on the right, so moving between posts never needs a back link. The shortcuts work the way they would in a terminal, and you can sort this table by clicking a column header:

keyboard shortcuts
1Open homeglobal
2Open blogglobal
jScroll downglobal
kScroll upglobal
g gJump to topglobal
GJump to bottomglobal
nOlder postblog
pNewer postblog
dToggle themeglobal
?Show shortcutsglobal

The motion is mostly CSS. The hero command types out with a steps() animation, panes rise in as they scroll into view, and the line under the tab bar and the percentage in the status bar both track scroll position. Everything is switched off under prefers-reduced-motion.

Writing posts

Posts are plain Markdown files in content/blog/<slug>/<locale>.md, rendered with TanStack Markdown. A missing translation falls back to the English file, and draft: true keeps a post out of production builds.

Three pieces of TanStack tooling do the rest:

  • Markdown parses and renders the post as React elements, with no HTML string in between.
  • Highlight colors code blocks. Only the languages I register are bundled, and the colors come from the same CSS variables as the rest of the theme.
  • Charts and Table are embedded with a one-line comment that names a JSON file next to the post.

Code blocks can carry a file title:

src/features/blog/embeds/index.tsx
export function createEmbeds(slug: string, locale: string) {
  return {
    chart: ({ data = "", kind = "bar", x = "x", y = "y", title = "Chart" }) => (
      <TuiChart
        rows={getPostDataset(slug, data, locale)}
        kind={kind}
        x={x}
        y={y}
        label={title}
      />
    ),
  }
}

Plain Markdown tables work too, and pick up the same styling:

LayerFolderResponsibility
Routesapp/Pages, metadata, icons, and images
UIcomponents/tui/Panes, navbar, status bar, keymap
Featuresfeatures/Home sections and the blog
Utilitieslib/, hooks/Metadata, observability, key input

Here is how many source files live in each folder at the time of writing:

Source files by folder
View data
folderfiles
app17
components16
lib16
features15
i18n4
hooks3
types1

Generated icons and social cards

Next.js 16 builds metadata images from files in the app/ directory:

  • icon.tsx and apple-icon.tsx render the favicon and the iOS icon from a single SVG, assets/logo-icon.svg.
  • opengraph-image.tsx renders the social card for the home page, and a second one renders a card for each post.

There is no favicon.tsx. The docs are explicit that a favicon can't be generated, so icon.tsx does that job. All of these share one small set of primitives, so changing the brand mark changes the favicon and every social card at once.

Instrumentation

Two files handle observability. instrumentation.ts runs on the server and reports request errors. instrumentation-client.ts runs before hydration and tracks client errors, web vitals, and route changes. Both hand events to a reporter that fans them out to sinks:

src/lib/observability/server.ts
export const serverReporter = createReporter([
  consoleSink,
  ...(endpoint ? [endpointSink(endpoint)] : []),
])

Client events are posted to /api/observability, which logs them. Pointing the reporter at a real collector later means adding one sink, not touching either entry file.

Translations

The site ships in ten languages, including right-to-left Arabic and Hebrew. Every string lives in one message file per locale; if a key is ever missing, the default locale's message is merged underneath, so a page never renders blank. Posts work the same way: each locale gets its own file, and a missing one falls back to English.

NORMAL~/blog/how-this-site-is-built
0%

Keyboard shortcuts

12
Primary
jk
Scroll down / up
g gG
Jump to top / bottom
np
Newer / older post
d
Toggle theme
:
Open command palette
?
Toggle this help
esc