Skip to Content
StatixFlow Next Docs
Writing Articles

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, no next.config.ts changes, 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):

content/articles/blog/my-post.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 .mdx file’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 via evaluate(), returning { meta, Content } where Content is 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

  1. Create content/articles/blog/<your-slug>.mdx.
  2. Add frontmatter following the schema above — at minimum title, excerpt, date, readTime, category, coverImage.
  3. Write the body as Markdown/MDX. For a table of contents, give each <h2> an explicit id matching an entry in toc.
  4. That’s it — no registration step. getAllArticles("blog") picks up any .mdx file 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.

Last updated on