Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. A Typed MDX Content Pipeline with Velite (Next.js Tutorial)

Iurii Builds: A Typed MDX Content Pipeline with Velite (Next.js Tutorial)

Zod-backed collections, computed fields, and a codegen step that runs before next build — so your content layer fails at build time, not in production.

August 19, 2026· 9 min read

Build a type-safe content layer with Velite: Zod schemas for MDX and JSON, computed fields, one typed import, and a related-posts codegen step.

Stack

Next.jsTypeScript

Services

MDX

Topics

ArchitectureContentBuild ToolsSEO
A Typed MDX Content Pipeline with Velite (Next.js Tutorial)

Most Next.js blogs read MDX at request time, hand you frontmatter as any, and discover a missing date or a typo in a tag when the page 500s in production. That works until the content set grows past a handful of posts. Then every rename, every new frontmatter field, every tag cleanup becomes a guessing game, because nothing checks the shape of your content until a user hits the route.

This site works differently. Content is compiled once, validated against Zod schemas, and emitted as typed JSON that the app imports like any other module. If a post is missing a required field, the build fails — not the page. Here's how it's wired, taken straight from the config that ships this site.

The Problem with Reading MDX at Request Time

The common pattern looks like this: a getPostBySlug() helper reads a file, runs gray-matter, and returns an object. TypeScript has no idea what's in the frontmatter, so you either cast to any or hand-write an interface that drifts from reality the moment someone adds a field in an .mdx file.

Three things go wrong as the content grows:

  • No validation. A post dated 2026-13-01 or missing a summary sails through until render.
  • No single source of truth. The frontmatter shape lives in your head; the TypeScript type lives in a types.ts that nobody updates.
  • Runtime cost. Parsing and compiling MDX on every request, or fighting the router's caching to avoid it.

Velite moves all of that to build time. You describe each content collection once, with a schema, and get back validated, typed data plus compiled MDX.

Defining a Collection with a Zod-Style Schema

Velite's s object is a thin wrapper over Zod with content-aware helpers (s.slug(), s.isodate(), s.mdx(), s.raw()). A collection binds a glob pattern to a schema. Here is the blog collection this site uses, trimmed to the fields that matter:

// velite.config.ts
import { defineCollection, s } from "velite";
 
const posts = defineCollection({
  name: "Post",
  pattern: "blog/**/*.mdx",
  schema: s.object({
    title: s.string().max(120),
    seoTitle: s.string().max(70).optional(),
    subtitle: s.string().max(220).optional(),
    keyphrase: s.string().max(80).optional(),
    slug: s.slug("posts"),
    summary: s.string().max(300),
    date: s.isodate(),
    updated: s.isodate().optional(),
    category: s.enum(["dev", "business"]).default("dev"),
    verb: s.string().max(20).optional(),
    cta: s
      .object({
        service: s.string().optional(),
        text: s.string().max(280).optional(),
      })
      .optional(),
    raw: s.raw(),
    body: s.mdx(),
  }),
});

A few of these earn their place:

  • s.slug("posts") validates the slug is URL-safe and unique across the posts set. Two posts with the same slug is a build error, not a silent overwrite.
  • s.isodate() rejects anything that isn't a real ISO date. The 2026-13-01 case above dies here.
  • s.enum(["dev", "business"]).default("dev") is how the editorial track is enforced. A post either declares one of two categories or defaults — you can't invent a third by typo.
  • s.raw() keeps the untouched Markdown source around; s.mdx() compiles the body to a function string the renderer executes. Having both means you can derive things from the raw text (word count, headings) that the compiled output has already thrown away.

The .max() limits aren't decoration. A summary capped at 300 characters and a seoTitle at 70 mean malformed metadata fails the build instead of quietly breaking a SERP snippet weeks later.

Sharing Schema Fragments

Tags on this site aren't a flat array — they're categorized into stack, libraries, databases, services, and topics. That structure is identical across posts, projects, and reviews, so it lives in one schema fragment:

const tagsSchema = s
  .object({
    stack: s.array(s.string()).default([]),
    libraries: s.array(s.string()).default([]),
    databases: s.array(s.string()).default([]),
    services: s.array(s.string()).default([]),
    topics: s.array(s.string()).default([]),
  })
  .default({});

Each collection reuses tags: tagsSchema. Change the categories once and every collection updates. This is the same DRY instinct you'd apply to a shared type — it just happens at the content-schema layer.

Computed Fields with .transform()

Raw frontmatter is rarely what the app wants to consume. You want a permalink, a reading time, a flattened tag array, an extracted table of contents. Velite's .transform() runs after validation and lets you derive all of it, so the emitted record is enriched and still fully typed.

schema: s
  .object({ /* fields above */ })
  .transform(async (data) => {
    return {
      ...data,
      tagCategories: data.tags,
      tags: [
        ...data.tags.stack,
        ...data.tags.libraries,
        ...data.tags.databases,
        ...data.tags.services,
        ...data.tags.topics,
      ],
      permalink: `/blog/${data.slug}`,
      readingTime:
        data.readingTime ??
        Math.max(1, Math.round(data.raw.split(/\s+/).length / 200)),
      wordCount: data.raw.split(/\s+/).filter(Boolean).length,
      headings: extractHeadings(data.raw),
      coverImage,
    };
  }),

What's happening here:

  • tags is flattened from the categorized object into a single string[] that components can iterate — while tagCategories preserves the grouped shape for anything that needs it. One source, two views.
  • readingTime falls back to a word-count estimate (words / 200) only when the frontmatter didn't set it. Author override wins; otherwise it's computed.
  • headings are extracted from the raw Markdown by a small parser that walks the source, skips fenced code blocks, and slugs each ##/### with github-slugger — the same slugger rehype-slug uses for anchors, so the table of contents links resolve.

The transform is async, which is what makes the next part possible.

Side Effects at Build Time: Cover Images

Because the transform runs at build time and can be async, it's also the right place to do content-adjacent asset work. When a cover.png sits next to index.mdx, the transform copies it into public/, generates a WebP for serving, and pre-pads OG and dev.to variants with sharp:

const coverSrc = path.join(process.cwd(), "content", "blog", data.slug, "cover.png");
let coverImage: string | undefined;
if (existsSync(coverSrc)) {
  const destDir = path.join(process.cwd(), "public", "images", "blog", data.slug);
  mkdirSync(destDir, { recursive: true });
  copyFileSync(coverSrc, path.join(destDir, "cover.png"));
  await ensureWebp(coverSrc, path.join(destDir, "cover.webp"));
  await ensureCoverVariant(coverSrc, path.join(destDir, "cover-og.png"), 1200, 630);
  await ensureCoverVariant(coverSrc, path.join(destDir, "cover-devto.png"), 1000, 420);
  coverImage = `/images/blog/${data.slug}/cover.webp`;
}

The variant helpers are idempotent — they check the destination's mtime against the source and skip work if nothing changed — so incremental builds stay cheap. That's the point of a build-time content layer: image derivation, format conversion, and metadata validation happen in one pass, and the running app just references paths that are guaranteed to exist.

One Typed Import

Velite writes the compiled collections to .velite/ (configured via output.data), and the app imports them as @/.velite:

import { posts, projects } from "@/.velite";

posts is a fully typed array. The category is "dev" | "business", not string. permalink, readingTime, headings — all present, all typed, because the transform put them there and Velite inferred the shape. There is no getPostBySlug() reading the filesystem at request time; there's an array in memory, generated at build.

A worthwhile guardrail on top of this: you could add an ESLint no-restricted-imports rule that forbids importing from .velite in application code directly, routing everything through a thin lib/ layer instead. That would keep the raw generated output an implementation detail and give you one place to add filtering (drafts, future-dated posts) without touching every call site.

The Codegen Step: A Related-Posts Graph

Typed content unlocks work that's awkward to do at runtime. This site computes a tag-overlap graph — related posts, related projects, related services — in a script that runs after velite build and before next build:

{
  "scripts": {
    "build": "velite build && tsx scripts/generate-relations.ts && next build"
  }
}

That's the relevant slice of the build chain — the real build script has one more codegen step after this (generate-cv.tsx, which prebuilds a PDF), unrelated to the content pipeline and out of scope here. The script reads the JSON Velite just emitted, scores every pair by shared tags, and writes .velite/relations.json:

// scripts/generate-relations.ts
import { services } from "../lib/services";
 
const posts: VeliteItem[] = JSON.parse(readFileSync(join(root, ".velite/posts.json"), "utf8"));
 
function tagOverlap(tagsA: string[], tagsB: string[]): number {
  return tagsA.filter((t) => tagsB.includes(t)).length;
}
 
function findRelated(sourceSlug, sourceTags, candidates, limit = 2): string[] {
  return candidates
    .filter((c) => c.slug !== sourceSlug)
    .map((c) => ({ slug: c.slug, overlap: tagOverlap(c.tags, sourceTags), date: c.date }))
    .filter((c) => c.overlap > 0)
    .sort(
      (a, b) => b.overlap - a.overlap || new Date(b.date).getTime() - new Date(a.date).getTime()
    )
    .slice(0, limit)
    .map((c) => c.slug);
}

The scoring is deliberately boring: count shared tags, sort by overlap descending, tie-break on newest first, take the top N. It runs across four relationship types — post→posts, post→projects, post→reviews, post→services — and because it reads the same categorized-then-flattened tags array the schema produced, the graph is consistent with what the site renders everywhere else.

Two design choices are worth calling out. First, services feed the graph from lib/services.ts, not from MDX — service definitions live in TypeScript with their own tags field, and the script imports them as the single source of truth for that side of the relation. Second, the whole thing is a build artifact: relations.json is written once, committed to nothing, and read as static data. No runtime tag-matching, no per-request cost, no way for the graph to disagree with the pages.

Where This Pays Off and Where It Doesn't

The build-time model earns its keep when:

  • content is large enough that manual consistency breaks down;
  • you cross-link content by shared metadata (tags, categories, series);
  • frontmatter mistakes should block a deploy, not surface as production 500s;
  • you want derived data — reading time, TOC, related items — computed once, not per request.

It's overhead you don't need when the site is three static pages and a contact form, or when content changes so often that a rebuild per edit is friction rather than safety. A CMS with runtime rendering fits that better. There's also a real constraint: everything lives in the repo and requires a build to publish. If non-technical editors need to publish without a deploy, this isn't the shape for them — that's a headless CMS decision, not a Velite one.

For a code-owned, developer-authored content set, the trade is clean: one schema, one typed import, validation at the build boundary, derived data generated in the same pass. The content layer stops being a source of runtime surprises and becomes just another typed module the app depends on.


If you're building a content-heavy Next.js product and want the content layer typed and validated from the start — the schema, the codegen, the guardrails that keep it from rotting — that's the kind of foundation I put in before feature work begins. Get in touch if you're spinning up an MVP and want it built on something that won't break silently as it grows.


Further reading:

  • Velite documentation
  • Generating dynamic OG images in Next.js — another build-time asset step in this pipeline
  • Writing effective CLAUDE.md rules for AI coding agents — how this content architecture is documented for tooling
Iurii RoguliaAvailable

MVP Development

Building a content-heavy Next.js product and want the content layer typed and validated at build time? That's the kind of foundation I put in before feature work starts.

More about this service

Relevant client work

View all projects
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

Pikkuna — E-commerce for Vinyl Curtains & PVC Products
Pikkuna — E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna — E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →

pi-pi.ee — B2B Deal & Document Portal
pi-pi.ee — B2B Deal & Document Portal
July 1, 2026
pi-pi.ee — B2B Deal & Document Portal

Internal sales portal that turns a wholesale deal into a full set of trade paperwork — pro forma, contract, commercial invoice, packing list, CMR and more —

What clients say

“

I'd built most of our MVP with Cursor and it looked finished — it compiled, the tests were green, the demo worked. It just wouldn't survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

I'd read about llms.txt and AI discoverability but had no idea whether any of it actually mattered for our SaaS docs site.

Dimitris Papadakis 🇬🇷

Founder

Topics

SEOllms.txtAIArchitecture
“

We publish 20-30 news articles per day and indexing latency was killing us — by the time Google crawled a story, the news cycle had moved on.

Andrei Popescu 🇷🇴

Engineering Manager

Topics

SEOIndexNowArchitecturePerformance

Related articles

Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps
December 15, 2025· 3 min
Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps

Technical SEO built into Next.js: server-side rendering, dynamic meta tags, JSON-LD structured data, and automatic sitemap generation — no plugins, just code.

Stack

Next.jsReactTypeScript

Topics

SEOArchitecturePerformance
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift
August 7, 2026· 10 min
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift

B2B quote-to-order flow in Next.js: an RFQ → quote → order state machine, per-deal line items, guarded status transitions, and converting an accepted quote

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceArchitectureSSRSales Automation
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access
July 24, 2026· 9 min
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access

Gated B2B pricing in Next.js: per-account price lists, server-side access control so prices never leak to crawlers, and a deal-record data model where every

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceAuthSSRArchitectureSecurity
Build an Internal CRM on Supabase: A Weekend-Scale Guide
June 24, 2026· 21 min
Build an Internal CRM on Supabase: A Weekend-Scale Guide

Build an internal CRM on Supabase: schema design, RLS policies, Next.js App Router frontend, realtime subscriptions, and an honest look at where the weekend

Stack

Next.jsTypeScript

Libraries

Drizzle ORM

Databases

PostgreSQL

Services

Supabase

Topics

CRMArchitectureSaaSInternal Tools