The content layer decision nobody schedules
Every Next.js project eventually faces the same quiet decision: where does content live? Not code — content. Headlines, labels, article bodies, metadata. Teams debate CMS vs. no CMS, MDX vs. plain Markdown, and typed objects vs. JSON files. For a small site, the debate can eat a day.
I just went through it twice in the same repository — this portfolio. The landing page and the blog needed different answers, and I think that's the useful part of this story: the right content layer is not a global constant. It depends on the shape of the content and who edits it.
Two approaches, in plain terms
Typed content objects. Copy lives in TypeScript files, shaped by an interface, checked at compile time. The landing page of this site uses exactly that: a ContentSchema interface, and both language dictionaries satisfy it.
export const id = {
nav: {
items: [
{ label: 'Pengalaman', href: '#experience' },
{ label: 'Karya', href: '#works' },
{ label: 'Kontak', href: '#contact' },
],
},
// …
} as const satisfies ContentSchema
Markdown files with frontmatter. Long-form text lives in .md files. Metadata (title, slug, date, excerpt, tags) sits in a frontmatter block; a loader parses it at build time. This blog works that way — each article is one file, and the page you're reading was rendered from one.
Both ship zero client JavaScript if you keep the rendering on the server. That's not the differentiator. The differentiators are typing, editing ergonomics, and metadata.
Why the landing page wanted typed objects
The landing copy is structured data, not prose. Nav items, work entries, stack groups, footer labels — all of it has shape. A missing key doesn't render as "undefined" in production; it fails the build, because the dictionary no longer satisfies the schema.
Two things made typed objects win here:
Bilingual parity. The site ships Indonesian and English dictionaries. A shared interface plus a parity test means a key added to one language and forgotten in the other breaks CI, not the layout. That guarantee is free with typed objects and awkward with Markdown.
Structural content, not text. Nav items are tuples of labels and anchors, not sentences. Work entries have a fixed set of fields with specific types. Forcing that into Markdown would mean inventing conventions inside frontmatter — effectively rebuilding a schema by hand, without the compiler.
Why the blog wanted Markdown
Articles are the opposite problem. The content is long-form prose. I write it in an editor with Markdown preview and spellcheck. A fenced code block is just a fenced code block — no escaping backticks, no JSX syntax leaking into prose.
Frontmatter gives me metadata without touching the body:
---
title: "Typed Content vs. MDX: Choosing a Content Layer for a Small Next.js Site"
slug: typed-content-vs-mdx
date: 2026-10-16
excerpt: "Why this portfolio runs two content layers on purpose."
tags: ["Next.js", "TypeScript", "Architecture"]
draft: false
---
A build-time loader reads the file, parses frontmatter once, and feeds everything that needs metadata — sitemap, RSS, the article list, reading time, social cards — without rendering a single paragraph. The body only gets rendered when the page itself is built.
Why not MDX
MDX is the option that always comes up next: Markdown, but with React components allowed inside. I considered it and rejected it, for reasons that hold beyond this project.
Metadata becomes a second-class citizen. In MDX, the file is a component. To get title and date for the sitemap, you still have to parse frontmatter separately — a second parse path that can drift from the first. Plain Markdown-plus-frontmatter gives you both from one read.
Content couples to the component tree. The moment prose can contain JSX, editing content means understanding the React version, the component APIs, and the build. That's fine for a design-system blog maintained by engineers. It's hostile to writing.
Typos become build errors — of the wrong kind. A missing closing tag in MDX fails the build with a compiler error, not a prose warning. You traded a writing problem for a tooling problem.
MDX is a legitimate choice when content is interactive: interactive docs, component playgrounds, product tours. For text with code blocks, it's overhead.
A decision checklist
When you're picking a content layer for your own project, these are the questions that actually moved the needle for me:
- Is the content structured or prose? Structured → typed object. Prose → Markdown.
- Who edits it? If anyone outside the engineering team will write it, don't make them open a TS file or learn JSX.
- Do you need metadata without rendering the body? Sitemap, RSS, list pages, and social cards all do. Frontmatter handles this cleanly; typed objects handle it too, but only if you design for it.
- How much i18n? Dictionaries with parity tests are a killer feature of typed objects. Markdown doesn't do this out of the box.
- How many pieces of content? For a handful of pages, either works. For hundreds of articles, file-per-article scales better than one giant typed file.
One project, two layers, no contradiction
This portfolio runs both, and I don't experience it as inconsistency. The landing page is a structured, bilingual interface — a typed object is the honest representation of that. The blog is a stream of long articles with metadata — one Markdown file per article is the honest representation of that.
The mistake isn't choosing wrong. The mistake is choosing once, globally, before looking at what the content actually is. Pick per surface. Let the compiler guard the structured parts, and let Markdown stay Markdown where it's just text.