"Headless CMS" usually means: your content lives in someone else's database, arrives over someone else's API, and costs a subscription once you pass the free tier. For a developer-run blog there's a simpler headless architecture — the content layer is a folder of markdown files, and the API is a typed library call.
Here's the full setup for Next.js App Router, ready to deploy on Vercel.
The content layer
npm install @yamblog/core @yamblog/next
// lib/blog.ts
import { defineBlog } from '@yamblog/core';
export const blog = defineBlog('content/posts');
That one line is your CMS. On Vercel you don't even need to configure the site URL — defineBlog auto-detects it from VERCEL_URL (or set NEXT_PUBLIC_SITE_URL for a custom domain). blog.siteUrl then flows into every generated URL: RSS, sitemap, canonical links.
Posts are markdown files with validated frontmatter:
<!-- content/posts/hello-world.md -->
---
title: "Hello World"
date: "2026-06-25"
tags: [intro]
excerpt: "First post."
---
Welcome!
Bad frontmatter fails next build with an error naming the file, so broken content never deploys.
The pages
// app/blog/page.tsx
import { BlogListPage } from '@yamblog/next';
import { blog } from '@/lib/blog';
export default async function Page() {
const posts = await blog.getPosts();
return <BlogListPage posts={posts} />;
}
// app/blog/[slug]/page.tsx
import { BlogPostPage, generatePostMetadata, createStaticParams } from '@yamblog/next';
import { blog } from '@/lib/blog';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
return createStaticParams(blog);
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await blog.findPostBySlug(slug);
if (!post) return {};
return generatePostMetadata(post, { siteUrl: blog.siteUrl, siteName: 'My Blog' });
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await blog.findPostBySlug(slug);
if (!post) notFound();
const adjacent = await blog.getAdjacentPosts(slug);
return <BlogPostPage post={post} prev={adjacent.prev} next={adjacent.next} />;
}
Two things worth noticing:
findPostBySlugreturnsnullfor a missing post, which maps cleanly onto Next'snotFound(). (If you prefer throwing,getPostBySlugthrows a typedPostNotFoundErroryou can catch withinstanceof— no string matching.)generatePostMetadataemits the OpenGraph, Twitter card, and canonical URL metadata so shared links unfurl properly.
Because everything renders in Server Components at build time, the "CMS query" costs nothing at request time — Vercel serves static HTML from the edge.
The SEO plumbing: three files
// app/feed.xml/route.ts
import { createRssHandler } from '@yamblog/next';
import { blog } from '@/lib/blog';
export const GET = createRssHandler(blog, {
title: 'My Blog',
description: 'Latest posts',
});
// app/sitemap.ts
import { createSitemapExport } from '@yamblog/next';
import { blog } from '@/lib/blog';
export default createSitemapExport(blog);
// app/blog/[slug]/opengraph-image/route.ts
import { createOgImageHandler } from '@yamblog/next';
import { blog } from '@/lib/blog';
export const GET = createOgImageHandler(blog, { siteName: 'My Blog' });
RSS 2.0 with a proper atom self-link, a sitemap Next serves natively, and per-post OG images rendered with @vercel/og. All of them read blog.siteUrl, and all of them exclude drafts.
The publish workflow
This is where the file-based approach quietly wins:
- Write
content/posts/my-new-post.mdon a branch. - Open a pull request — Vercel builds a preview deployment. Set
includeDrafts: process.env.VERCEL_ENV === 'preview'indefineBlogand reviewers can even readdraft: trueposts on the preview URL, while RSS and the sitemap keep excluding them. - Merge. Production rebuilds. The post is live, in the feed, and in the sitemap.
Publishing is a git push. Rollback is a git revert. Your "CMS audit log" is git log. And there's no vendor to migrate away from later, because the content was never anywhere but your repo.
The Next.js recipe covers the extras — JSON-LD, breadcrumbs, search, and custom remark pipelines — when you're ready for them.