Building a Local-First MDX Journal

A practical architecture for a statically generated editorial website whose content remains readable, portable, and version controlled.

Published
By
Author Name
Reading
3 minutes
Layered pages connected to a simple software architecture diagram

Abstract

A local-first publication can retain the convenience of a modern web application without surrendering ownership of its writing to a database or external content platform.

The architectural premise#

The smallest durable publishing system is a directory of text files and a deterministic build. Markdown remains readable without the application, Git records its history, and static generation turns each publication into ordinary HTML.

The core pipeline has four responsibilities:

  1. discover content recursively;
  2. validate metadata;
  3. compile MDX on the server;
  4. generate every index derived from published content.

A validated content boundary#

Frontmatter is an external input even when it lives in the repository. Treating it as typed data produces clearer errors and prevents malformed entries from silently entering navigation, metadata, or feeds.

schema.ts
import { z } from "zod"
 
export const articleSchema = z.object({
  title: z.string().min(1),
  summary: z.string().min(20),
  category: z.string().min(1),
  tags: z.array(z.string()).default([]),
  publishedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  status: z.enum(["draft", "published"]),
})

A failed parse should identify the source file and preserve the validation details. Build failure is preferable to publishing inconsistent metadata.

Recursive discovery without registration#

A registry array creates two sources of truth: the content file and the registry. Recursive discovery removes that duplication.

find-content-files.ts
import { readdir } from "node:fs/promises"
import path from "node:path"
 
export async function findContentFiles(directory: string): Promise<string[]> {
  const entries = await readdir(directory, { withFileTypes: true })
 
  const nested = await Promise.all(
    entries.map(async (entry) => {
      const target = path.join(directory, entry.name)
      if (entry.isDirectory()) return findContentFiles(target)
      return /\.mdx?$/.test(entry.name) ? [target] : []
    }),
  )
 
  return nested.flat()
}

Derived indexes#

Categories, tags, RSS, search, recent writing, related articles, and the sitemap should all derive from the same published collection. The rule is simple: no page should require a second manual edit after adding an article.

Why static generation remains valuable#

Static generation reduces operational surface area. There is no content database to back up, no public write API to secure, and no request-time dependency between a reader and the source material. Dynamic behavior is reserved for interactions that genuinely need it: search, theme preference, navigation state, and copying code.

Limits and future thresholds#

A separate backend becomes justified when the publication acquires persistent user-generated data, authentication, private material, administrative workflows, background jobs, or multiple external clients. Until then, a server-rendered content pipeline and static output remain easier to understand and maintain.