A technical guide to the fastest open-source ways to add a blog to a modern Next.js app in 2026. Featuring MDX, Content Collections, Nextra, TinaCMS, Decap CMS and Better Stack’s blog plugin.

In 2026, adding a blog to a modern Next.js app is no longer synonymous with adopting a full CMS. If you’re building a product and you want content (marketing, changelog, docs-ish posts) without dragging in an entire platform, the ecosystem has matured into a few clear “dev-first” lanes:
Plugin-based full-stack blog (routes + DB + editor + SEO + RSS included)
File-based MD/MDX (simple, fast, in-repo)
Type-safe content pipelines (schema validation + generated types)
Turnkey blog frameworks (instant theme + routing)
Git-based editors (visual editing, content stays in Git)
This post focuses on free + open-source options and skips traditional CMS / hosted headless platforms (WordPress, Contentful, Strapi, etc.). You’ll get setup snippets, tradeoffs, and quick “pick this if…” guidance.
Here are the most common approaches devs actually ship:
.mdx directly in your app and render it as routes. Great for dev-owned content in Git.next-mdx-remoteLet’s go through them in the order most teams should evaluate them.
If your blog is mostly dev-authored and you’re happy keeping posts in Git, the simplest solution is still: MDX in your repo.
// next.config.mjs
import withMDX from "@next/mdx"({ extension: /\.mdx?$/ })
export default withMDX({
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
})
Example post:
export const metadata = { title: "Hello World", description: "First post" }
# Hello World
This is my **first blog post**.
<NewsletterSignup />
Pros
Minimal moving parts
Great SEO (SSG/SSR output HTML)
Easy MDX components
Cons
You build indexing/tags/feeds yourself
Frontmatter is untyped unless you add validation
Builds can slow down with huge content libraries
Pick this if: “It’s mostly a dev blog, content lives in Git, and I want maximum control.”
next-mdx-remote (Flexible Fetching)#If you want MDX but don’t want .mdx files to be actual route modules, next-mdx-remote lets you load from wherever and render.
// pages/blog/[slug].tsx
import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote"
import { serialize } from "next-mdx-remote/serialize"
import fs from "fs"
import path from "path"
export async function getStaticPaths() {
const dir = path.join(process.cwd(), "posts")
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".mdx"))
return {
paths: files.map((f) => ({ params: { slug: f.replace(/\.mdx$/, "") } })),
fallback: false,
}
}
export async function getStaticProps({ params }: { params: { slug: string } }) {
const filePath = path.join(process.cwd(), "posts", `${params.slug}.mdx`)
const source = fs.readFileSync(filePath, "utf8")
const mdxSource = await serialize(source, { parseFrontmatter: true })
return { props: { source: mdxSource } }
}
export default function PostPage({ source }: { source: MDXRemoteSerializeResult }) {
return <MDXRemote {...source} />
}
Pros
Content can live outside your route tree
Can support remote sources
Still MDX
Cons
More wiring (slugs, metadata, indexing)
SSR parsing can impact TTFB unless cached
Pick this if: “I need flexible sourcing (or a massive repo) but I still want MDX.”
If you want schema validation + generated types for posts, a content pipeline is usually the best long-term “file-based” solution.
// content-collections.ts
import { defineCollection, defineConfig } from "@content-collections/core"
const BlogPost = defineCollection({
name: "BlogPost",
directory: "content/blog",
include: "*.mdx",
schema: (z) => ({
title: z.string(),
date: z.string().datetime(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
})
export default defineConfig({
collections: [BlogPost],
})
Pros
Best TypeScript ergonomics
Enforces frontmatter rules
Easy sorting/filtering without filesystem scanning in every route
Cons
Additional build step + config
Still file-based (publishing implies deploy/rebuild unless you add infra)
Pick this if: “My blog will grow and I care about strict content shape and DX.”
Nextra is still one of the fastest “I want a blog right now” options.
npm install nextra nextra-theme-blog
// next.config.mjs
import nextra from "nextra"
const withNextra = nextra({
theme: "nextra-theme-blog",
themeConfig: "./theme.config.ts",
})
export default withNextra({})
Pros
Super fast setup
Nice defaults (routing/layout/docs-ish ergonomics)
Cons
Conventions can be constraining for app-style blogs
Deep customization can get tricky
Pick this if: “I’m okay adopting a theme-driven blog stack to ship immediately.”
If non-devs need to publish but you still want content in Git:
# public/admin/config.yml
backend:
name: git-gateway
branch: main
media_folder: "public/uploads"
public_folder: "/uploads"
collections:
- name: "posts"
label: "Posts"
folder: "content/posts"
create: true
slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
fields:
- { name: "title", label: "Title", widget: "string" }
- { name: "date", label: "Date", widget: "datetime" }
- { name: "tags", label: "Tags", widget: "list", default: [] }
- { name: "body", label: "Body", widget: "markdown" }
Better for component-based editing and richer workflows, but integration/auth can be more involved.
Pick these if: “Publishing needs a UI, but we’re committed to Git-stored content.”
If you want the fastest path to a real blog feature inside a full-stack app—without bolting together routing + content storage + editor UI + SEO + RSS yourself—Better Stack’s Blog plugin is built for that exact use case.
Better Stack treats “Blog” as a full-stack plugin, not a folder of markdown:
Backend plugin adds DB schema + typed API endpoints + hooks (auth/logging/policies)
Client plugin adds routes + SSR loaders + SEO metadata + React Query hydration
UI included: editor, drafts, post pages, tags, etc.
Adapters: Prisma, Drizzle, Kysely, MongoDB (and memory for dev/tests)
If your blog needs drafts, tags, CRUD, multi-author workflows, protected admin routes, image uploads, or you want content in a DB (not just Git), this usually beats rolling your own.
npm install @btst/stack @tanstack/react-query @btst/adapter-prisma
// lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { blogBackendPlugin } from "@btst/stack/plugins/blog/api"
import { createPrismaAdapter } from "@btst/adapter-prisma"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
const { handler, dbSchema } = betterStack({
basePath: "/api/data",
plugins: {
blog: blogBackendPlugin({
onBeforeCreatePost: (_data, ctx) => isBlogAdmin(ctx.headers as Headers),
onBeforeUpdatePost: (_id, _data, ctx) => isBlogAdmin(ctx.headers as Headers),
onBeforeDeletePost: (_id, ctx) => isBlogAdmin(ctx.headers as Headers),
onBeforeListPosts: (filter, ctx) => {
// Allow public listing of published posts; restrict drafts
if (filter.published === false) return isBlogAdmin(ctx.headers as Headers)
return true
},
}),
},
adapter: (db) =>
createPrismaAdapter(prisma, db, {
provider: "postgresql",
}),
})
export { handler, dbSchema }
// Your auth helper
function isBlogAdmin(_headers: Headers) {
return true // replace with real auth check
}
// app/api/data/[[...all]]/route.ts
import { handler } from "@/lib/better-stack"
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
/* app/globals.css */
@import "@btst/stack/plugins/blog/css";
// lib/better-stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
process.env.NEXT_PUBLIC_BASE_URL || process.env.BASE_URL || "http://localhost:3000"
export const getStackClient = (queryClient: QueryClient) => {
const baseURL = getBaseURL()
return createStackClient({
plugins: {
blog: blogClientPlugin({
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient,
seo: {
siteName: "My Blog",
author: "Your Name",
twitterHandle: "@yourhandle",
locale: "en_US",
defaultImage: `${baseURL}/og-image.png`,
},
}),
},
})
}
// app/pages/[[...all]]/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { notFound } from "next/navigation"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/better-stack-client"
import { normalizePath } from "@btst/stack/client"
export default async function Page({ params }: { params: Promise<{ all: string[] }> }) {
const pathParams = await params
const path = normalizePath(pathParams?.all)
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(path)
if (route?.loader) await route.loader()
const dehydratedState = dehydrate(queryClient)
return (
<HydrationBoundary state={dehydratedState}>
{route?.PageComponent ? <route.PageComponent /> : notFound()}
</HydrationBoundary>
)
}
Pages:
/blog (home)
/blog/drafts
/blog/new
/blog/:slug
/blog/:slug/edit
/blog/tag/:tagSlug
API:
GET /posts, POST /posts, PUT /posts/:id, DELETE /posts/:id
GET /tags, GET /posts/next-previous
SEO helpers, SSR loaders, React Query hydration, hooks for auth/policies
Optional sitemap aggregation (if you enable sitemap generation)
Pick it if you want a real product-grade blog inside your app:
content in DB (not only Git)
drafts + CRUD + auth rules
editor UI (not “edit markdown in VS Code”)
consistent SSR + caching + SEO + sitemap/RSS patterns
minimal glue code
| Option | Storage | Editing UX | SEO/SSR | Type Safety | “Blog features” out of box | Best for |
|---|---|---|---|---|---|---|
| Better Stack Blog plugin | DB (via adapter) | ✅ Editor + drafts | ✅ SSR + meta + sitemap/RSS hooks | ✅ (plugin contracts) | ✅✅✅ | Product apps that want a real blog feature |
| Native MDX pages | Git | ❌ (dev-only) | ✅ | ❌ (unless custom) | ❌ | Simple dev blogs |
next-mdx-remote | Anywhere | ❌/⚠️ | ✅ | ❌ | ❌ | Flexible sourcing / large content |
| Content Collections | Git | ❌ (dev-only) | ✅ | ✅✅✅ | ❌ | Typed content pipelines |
| Nextra | Git | ❌ (dev-only) | ✅ | ⚠️ | ✅✅ | “Blog now” with conventions |
| Tina/Decap | Git | ✅ UI | ✅ | ⚠️ | ❌ | Non-dev authoring on file content |
If you want a top-tier blog feature inside a full-stack app: ✅ Better Stack Blog plugin
If content is dev-only and you want absolute control: Native MDX pages
If you need remote/flexible content sources: next-mdx-remote (+ caching/ISR)
If you want schema validation + TS types: Content Collections
If you want “done in 10 minutes” with a theme: Nextra
If non-devs must edit but content stays in Git: TinaCMS / Decap CMS
In 2026, the “best blog” for a Next.js app depends less on rendering (they can all be fast + SEO-friendly) and more on workflow and feature surface.
If you’re building a product and the blog is a real feature (drafts, editing UI, auth, image uploads, tags, RSS, SEO, stable routes), a plugin-based approach is often the shortest path to “production-grade” without weeks of glue code—making Better Stack’s Blog plugin one of the strongest OSS choices for devs who want to ship fast and keep full-stack ownership.