A Type-Safe Blog With No CMS, No Markdown Parser, and No Regrets
Why we write posts as TypeScript files instead of markdown or a headless CMS — and the block model that makes code snippets, figures, and callouts feel first-class.
CodeDevlify Editorial
Product & Platform team
Every blog starts with the same fork in the road: a headless CMS, a pile of markdown files, or something bespoke. We took the third road, and the whole content layer is about two hundred lines. Here's the reasoning and the shape of it.
What a CMS would have bought us
Non-technical publishing, scheduled posts, and an editor UI. All real. But our authors are the engineers who ship the platform, our release cadence is a pull request, and our review process is code review. A CMS would have added a network dependency at build time, a second permissions system, and a webhook to keep in sync — to solve a problem we didn't have.
Why not plain markdown, then?
Markdown is excellent at prose and mediocre at everything a technical post actually needs. The moment you want a code block with a filename tab and two highlighted lines, or a figure with a caption and correct dimensions, you're writing MDX or inventing directive syntax — and then you're maintaining a parser.
Structured blocks skip that. A post body is an array of tagged unions, so the compiler knows a code block has a language and an image block has a width. Forget one and the build fails before the post is ever deployed.
export type BlogBlock = | { type: "p"; text: string } | { type: "h2"; text: string; id: string } | { type: "ul"; items: string[] } | { type: "quote"; text: string; attribution?: string } | { type: "image"; image: BlogImage } | { type: "code"; language: CodeLanguage; filename?: string; code: string; highlight?: number[]; };Rendering is a switch over that union. TypeScript's exhaustiveness checking means adding a block type without handling it is a compile error, not a blank space in a published article.
The file layout
src/├── content/blog/│ ├── posts/ # one file per post, default-exports a BlogPost│ │ └── my-post.ts│ ├── authors.ts│ ├── categories.ts│ └── index.ts # the registry + lookup helpers├── components/blog/ # card, block renderer, code block, TOC└── app/(root)/blogs/ ├── page.tsx # the index └── [slug]/page.tsx # a single post public/blog/<slug>/ # cover.svg, thumbnail.svg, inline figuresImages live in public/ beside nothing else, keyed by slug. That means an article and its assets move, rename, or get deleted together, and next/image can serve them without a loader or a remote pattern in the config.
Static by default
Because the content is in the bundle, generateStaticParams can enumerate every post and Next renders all of them at build time. There is no revalidation window, no cache to purge, and no runtime data fetch that can fail while somebody is reading.
export function generateStaticParams() { return getAllPosts().map((post) => ({ slug: post.slug }));} export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const post = getPostBySlug((await params).slug); if (!post) return {}; return { title: post.title, description: post.excerpt, alternates: { canonical: `/blogs/${post.slug}` }, openGraph: { type: "article", images: [post.cover.src] }, };}When this stops working
Honestly: when a non-engineer needs to publish without a deploy, or when the archive grows past a few hundred posts and the bundle starts to notice. Both are real ceilings. Neither is close, and the migration path — blocks are already JSON-shaped — stays open.