CodeDevlify LogoCodeDevlify
All articles
Engineering

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

8 min read
A TypeScript file on the left resolving into a rendered article on the right.

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.

src/types/blog.types.tsTypeScript
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

Shell
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 figures

Images 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.

src/app/(root)/blogs/[slug]/page.tsxTSX
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.

nextjstypescriptcontentstatic-renderingdx
ShareXLinkedIn

Keep reading

Engineering

Anatomy of a 30-Second Site Provision

What actually happens between clicking “Create site” and a live HTTPS URL — the gRPC call, the container, the DNS wait, and the four places we had to stop blocking the request.

11 min read
Performance

Core Web Vitals Without the Headache

The four front-end decisions that move LCP, INP, and CLS more than any amount of micro-optimisation — with the measurements that tell you whether they worked.

7 min read

New posts, roughly twice a month

Engineering write-ups, performance notes, and what we learned running the platform. No product announcements dressed up as articles.

Unsubscribe in one click. We never share your address.