Writing Articles
Blog posts and case studies both go through the same MDX pipeline. This page covers the schema, how it’s compiled, and how to add a new post.
Why articles work differently from the rest of the site
Everywhere else on the site, copy lives in typed content/*.tsx objects (see
Content Editing). Articles are the one exception: they’re plain
.mdx files under content/articles/<kind>/<slug>.mdx (kind is blog or
case-studies), each with YAML frontmatter parsed by gray-matter.
Rather than importing .mdx files as modules (the @next/mdx webpack/Turbopack loader
approach), the body is compiled and run on demand with @mdx-js/mdx’s evaluate().
This is intentional — from the comment block at the top of content/articles.tsx:
- the set of files is dynamic (driven by whatever’s in the content directory, not a fixed
list of static imports), so Turbopack’s static analysis of
import()with a computed path is not a good fit; evaluate()just compiles a markdown/JSX string to a React component in plain JS — no bundler configuration, nonext.config.tschanges, and it works identically in a Route Handler, a Server Component, or a script;- it still reads real files from disk at request/build time, so it remains fully file-based and build-local — nothing is fetched from a remote service.
Next.js caches the rendered output of statically-generated routes, so the per-request
compile cost only really applies during next dev.
Frontmatter schema
Taken from a real post (content/articles/blog/ai-powered-customer-segmentation.mdx):
---
title: "AI-Powered Customer Segmentation Done Right"
excerpt: "Moving beyond basic demographic buckets into predictive, behavior-driven cohorts that actually convert."
date: "2026-01-14"
readTime: "7 min read"
category: "Engineering"
coverImage: "/imgs/layout-content/feature-img-10.webp"
author:
name: "Amara Osei"
role: "VP of Engineering at StatixFlow"
avatarSrc: "/imgs/avatars/avatar-9.webp"
tags: ["AI", "Engineering", "Segmentation"]
toc:
- id: "beyond-demographic-buckets"
label: "Beyond demographic buckets"
- id: "building-behavioral-cohorts"
label: "Building behavioral cohorts"
---
Body content goes here as Markdown/MDX. Headings used in `toc` need matching `id`s, e.g.:
<h2 id="beyond-demographic-buckets">Beyond demographic buckets</h2>Common fields (both blog and case-studies): title, excerpt, date, readTime,
category, categories, coverImage, author ({ name, role, avatarSrc }), tags,
toc ({ id, label }[]).
Case studies additionally support: client, industry, timeline, platform,
services, teamLead, clientSponsor, stats, approach, results, testimonial,
deliverables, gallery — see the ArticleMeta interface in content/articles.tsx for
the exact shape of each.
Deliverable icons
deliverables.items[n].icon in frontmatter is a string, e.g. "<ScanSearch />" — YAML
can’t hold a JS import, so it can’t be a direct lucide-react component reference the way
icons in content/*.tsx files are (see
Content Editing). Instead, toMeta() in
content/articles.tsx resolves each icon string to a real component via a private
deliverableIcons lookup + resolveDeliverableIcon() helper defined at the top of that
file, before the ArticleMeta is returned — so every component downstream still receives
an actual LucideIcon component, never a string.
If you use a new icon name in a deliverables entry, add it to deliverableIcons in
content/articles.tsx too — an unrecognized name throws at parse time rather than
silently rendering nothing.
The entry points
content/articles.tsx exposes exactly two functions — everything else in the app consumes
articles through these:
getAllArticles(kind)— reads every.mdxfile’s frontmatter (not the body) for a given kind, returns them sorted newest-first. Used for listing/index pages and prev/next/related lookups.getArticleBySlug(kind, slug)— reads one article’s frontmatter and compiles its body viaevaluate(), returning{ meta, Content }whereContentis a renderable React component.
Both app/blog/[slug]/page.tsx and app/case-studies/[slug]/page.tsx follow the same
shape:
export function generateStaticParams() {
return getAllArticles("blog").map((article) => ({ slug: article.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const resolved = await getArticleBySlug("blog", slug);
// ...
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const resolved = await getArticleBySlug("blog", slug);
if (!resolved) notFound();
const { meta, Content } = resolved;
// ...render <Content /> inside the article layout
}params is a Promise in Next 16 — both the page and generateMetadata await it
before use. If you’re used to older Next versions where params was a plain object,
this is the change to watch for; see FAQ for more on this.
Add a new blog post
- Create
content/articles/blog/<your-slug>.mdx. - Add frontmatter following the schema above — at minimum
title,excerpt,date,readTime,category,coverImage. - Write the body as Markdown/MDX. For a table of contents, give each
<h2>an explicitidmatching an entry intoc. - That’s it — no registration step.
getAllArticles("blog")picks up any.mdxfile in the directory automatically, so the post appears in listings and gets a static route at/blog/<your-slug>on the next build (or immediately in dev).
Case studies work identically, just under content/articles/case-studies/ with the
additional case-study-only fields.