Skip the CMS setup, custom APIs, and database headaches. Learn how to add a production-ready blog to your Next.js app in under 15 minutes using Better Stack's modular plugin system.

Every Next.js project eventually needs a blog. Marketing wants content. SEO demands fresh pages. Your users expect updates. And yet, adding a blog to an existing Next.js app remains surprisingly painful.
The traditional options? Wire up a headless CMS with webhooks, API routes, and ISR caching strategies. Or build a custom solution from scratch — database schemas, CRUD endpoints, markdown parsing, SEO meta tags, draft previews, and an editor that doesn't feel like it's from 2015.
Neither path is fast. Neither is fun.
There's a better way.
What if adding a blog was as simple as installing a package and wiring up a few lines of configuration? Not a black-box SaaS integration. Not a starter template you'll spend days customizing. A real, production-grade blog system that drops into your existing architecture — typed, extensible, and yours to own.
This is what modular full-stack features look like. And in 2026, this isn't theoretical. It's here.
Better Stack provides a blog plugin that bundles everything: database schemas, API routes, React components, SEO metadata, and server-side rendering — all in a single installable unit that respects your Next.js patterns.
Let's build it.
Before we start, make sure your Next.js project has:
shadcn/ui installed with CSS variables enabled
Sonner <Toaster /> configured for notifications
TailwindCSS v4 set up
@tanstack/react-query installed
If you're starting fresh, the shadcn CLI handles most of this:
npx shadcn@latest init
npx shadcn@latest add sonner
Add the core package and a database adapter. We'll use Prisma in this guide, but Better Stack supports multiple adapters:
@btst/adapter-prisma — For Prisma ORM (PostgreSQL, MySQL, SQLite, CockroachDB)
@btst/adapter-drizzle — For Drizzle ORM
@btst/adapter-kysely — Any Kysely suported dialect
@btst/adapter-mongodb — For MongoDB
@btst/adapter-memory — For development and testing
npm install @btst/stack @tanstack/react-query @btst/adapter-prisma
Create lib/better-stack.ts to configure your API handler and register the blog plugin:
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()
},
adapter: (db) => createPrismaAdapter(prisma, db, {
provider: "postgresql"
})
})
export { handler, dbSchema }
This single configuration gives you:
Full CRUD API for posts and tags
Database schema definitions
Type-safe data layer abstraction
Next.js App Router needs a catch-all route to handle Better Stack requests:
import { handler } from "@/lib/better-stack"
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
That's your entire API layer. No manual endpoint definitions. No request parsing boilerplate.
Run the CLI to generate your Prisma schema:
npx @btst/cli generate --config=lib/better-stack.ts --orm=prisma --output=prisma/schema.prisma
Then run your migration:
npx prisma migrate dev --name add-blog
Create lib/better-stack-client.tsx to configure client-side routing and data fetching:
import { createStackClient } from "@btst/stack/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
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: queryClient,
seo: {
siteName: "My Blog",
author: "Your Name",
twitterHandle: "@yourhandle",
locale: "en_US",
defaultImage: `${baseURL}/og-image.png`,
},
})
}
})
}
Create a query client utility for proper SSR hydration:
import { QueryClient, isServer } from "@tanstack/react-query"
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: isServer ? 60 * 1000 : 0,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false
},
dehydrate: {
shouldDehydrateQuery: () => true
}
}
})
}
let browserQueryClient: QueryClient | undefined = undefined
export function getOrCreateQueryClient() {
if (isServer) {
return makeQueryClient()
}
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
Add the provider to your root layout:
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
export default function RootLayout({ children }) {
const queryClient = getOrCreateQueryClient()
return (
<html>
<body>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</body>
</html>
)
}
Add the blog CSS to your global stylesheet:
@import "@btst/stack/plugins/blog/css";
Set up framework-specific overrides for the blog pages:
"use client"
import { BetterStackProvider } from "@btst/stack/context"
import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
import Link from "next/link"
import Image from "next/image"
import { useRouter } from "next/navigation"
const getBaseURL = () =>
typeof window !== 'undefined'
? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:3000")
type PluginOverrides = {
blog: BlogPluginOverrides
}
export default function Layout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const baseURL = getBaseURL()
return (
<BetterStackProvider<PluginOverrides>
basePath="/pages"
overrides={{
blog: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (path) => router.push(path),
refresh: () => router.refresh(),
uploadImage: async (file) => {
// Implement your image upload logic
// e.g., upload to Vercel Blob, S3, Cloudinary
return "https://example.com/uploads/image.jpg"
},
Link: (props) => <Link {...props} />,
Image: (props) => <Image {...props} />,
}
}}
>
{children}
</BetterStackProvider>
)
}
This catch-all page handles routing, SSR, and metadata generation:
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 { metaElementsToObject, normalizePath } from "@btst/stack/client"
import { Metadata } from "next"
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 && route.PageComponent ? <route.PageComponent /> : notFound()}
</HydrationBoundary>
)
}
export async function generateMetadata({ params }: { params: Promise<{ all: string[] }> }): Promise<Metadata> {
const pathParams = await params
const path = normalizePath(pathParams?.all)
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(path)
if (!route) return notFound()
if (route?.loader) await route.loader()
return route.meta ? metaElementsToObject(route.meta()) : { title: "Blog" }
}
Navigate to /pages/blog and you'll see your blog homepage. Here's what you get out of the box:
Pages:
/pages/blog — Blog homepage with published posts
/pages/blog/drafts — Draft management
/pages/blog/new — Rich markdown editor for new posts
/pages/blog/:slug — Individual post pages with SEO
/pages/blog/:slug/edit — Edit existing posts
/pages/blog/tag/:tagSlug — Tag-filtered views
Features:
Server-side rendering with data prefetching
Automatic SEO meta tags and Open Graph
Markdown editor with live preview
Draft and publish workflow
Tag management
Responsive, styled components
API Endpoints:
GET /api/data/posts — List posts with filtering
POST /api/data/posts — Create posts
PUT /api/data/posts/:id — Update posts
DELETE /api/data/posts/:id — Delete posts
GET /api/data/tags — List tags
Better Stack generates sitemaps from all registered plugins. Next.js has native sitemap support:
import type { MetadataRoute } from "next"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/better-stack-client"
export const dynamic = "force-dynamic"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const queryClient = new QueryClient()
const stackClient = getStackClient(queryClient)
return stackClient.generateSitemap()
}
This automatically includes all blog posts with their URLs, last modified dates, and change frequencies — no manual configuration needed.
The blog plugin accepts lifecycle hooks for authorization:
import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api"
const blogHooks: BlogBackendHooks = {
onBeforeCreatePost(data, context) {
return isAdmin(context.headers as Headers)
},
onBeforeUpdatePost(postId, data, context) {
return isAdmin(context.headers as Headers)
},
onBeforeDeletePost(postId, context) {
return isAdmin(context.headers as Headers)
},
}
const { handler, dbSchema } = betterStack({
plugins: {
blog: blogBackendPlugin(blogHooks)
},
// ...
})
You just added a production-ready blog to Next.js without:
Setting up a headless CMS
Writing CRUD endpoints
Building a markdown editor
Configuring SEO meta tags
Creating database schemas manually
Wiring up client-state management
This is the power of modular full-stack features. Not scaffolding. Not boilerplate generation. Real, typed, ownable code that integrates with your architecture.
The future of web development isn't about AI writing your code. It's about assembling better building blocks. Better Stack is one piece of that future — a plugin system that treats features like components, not projects.
Build with better blocks.
Resources:
Full Installation Guide — Complete setup instructions for all frameworks and adapters
Database Adapters — Detailed configuration for Prisma, Drizzle, Kysely, and MongoDB
Powered by Better-Stack