Add a blog to an existing Next.js site
This is for a different situation than the rest of these guides: you already
have a Next.js website — your own layout, header, footer, design system —
and you just want /blog and /blog/[slug] added to it. You are not
adopting the framework's docs shell, sidebar, or theming. You're using two
standalone pieces — MDX-with-frontmatter loading, and MDX rendering — and
wiring them into pages that look like the rest of your site.
What you're adding
@inkform/framework/content— readscontent/blog/*.mdx(frontmatter + body) off disk at build time. No database, no CMS required.@inkform/framework/mdx— renders a post's MDX body to React, with syntax highlighting and the framework's built-in content blocks (<Callout>,<Card>,<Steps>,<Tabs>,<CodeGroup>,<Accordion>, and more).
Nothing else. DocsShell, the sidebar, search, and the theme tokens are a
separate, optional layer for people building a full docs site — skip all of
it here.
1. Install
npm install @inkform/frameworkIt expects the same major versions of react, react-dom, and next your
project already has (React 19, Next 16) — no separate copies get pulled in.
2. Point Next at the package
Add transpilePackages to your existing next.config.ts — don't
replace the file, just merge this key in:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// ...whatever you already have
transpilePackages: ['@inkform/framework'],
};
export default nextConfig;The package ships TypeScript/JSX source rather than pre-compiled output, so Next needs to know to transpile it.
3. Add your content folder
content/
blog/
my-first-post.mdx
another-post.mdx
By default this is read from <project root>/content/blog. If you'd rather
keep content somewhere else in your repo, set DOCS_CONTENT_ROOT (env var)
to that directory instead — everything downstream still just calls
loadBlogPosts()/loadBlogPost().
Each file needs frontmatter:
---
title: My First Post
date: 2026-01-15
author: Jane Doe
description: A one-line summary for the list page.
---
Post body in MDX. Regular markdown, plus `<Callout>`, `<Card>`, fenced code
blocks with syntax highlighting, etc.See the Blog guide for the full frontmatter reference
(tags, series, scheduled/draft posts via status, cover images) — it's the
same loader, so every field there works here too.
4. Register your MDX component map
// mdx-components.tsx
import { mdxComponents } from '@inkform/framework/components';
export const blogMdxComponents = mdxComponents();If you want to use a custom React component inside post MDX (a chart, an
embed, anything project-specific), pass a { Name: Component } map into
mdxComponents() and it merges with the built-ins.
5. The list page — app/blog/page.tsx
Use your own page shell here (whatever wraps the rest of your site's
pages) instead of the framework's DocsShell:
import Link from 'next/link';
import { loadBlogPosts } from '@inkform/framework/content';
import { SiteLayout } from '@/components/site-layout'; // your own layout
export const metadata = { title: 'Blog' };
export default function BlogIndexPage() {
const posts = loadBlogPosts();
return (
<SiteLayout>
<h1>Blog</h1>
<ul>
{posts.map((p) => (
<li key={p.slug}>
<Link href={`/blog/${p.slug}`}>{p.title}</Link>
<p>{p.date} · {p.readingTime} min read{p.author ? ` · ${p.author}` : ''}</p>
{p.description ? <p>{p.description}</p> : null}
</li>
))}
</ul>
</SiteLayout>
);
}6. The post page — app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Mdx } from '@inkform/framework/mdx';
import { loadBlogPosts, loadBlogPost } from '@inkform/framework/content';
import { blogMdxComponents } from '@/mdx-components';
import { SiteLayout } from '@/components/site-layout';
export const dynamicParams = false;
export function generateStaticParams() {
return loadBlogPosts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return { title: loadBlogPost(slug)?.title ?? 'Blog' };
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = loadBlogPost(slug);
if (!post) notFound();
return (
<SiteLayout>
<h1>{post.title}</h1>
<p>{post.date} · {post.readingTime} min read{post.author ? ` · ${post.author}` : ''}</p>
<Mdx source={post.content} components={blogMdxComponents} />
</SiteLayout>
);
}Mdx is an async server component — it compiles and highlights the MDX at
request/build time, so nothing extra to await at the call site.
7. Styling
Mdx outputs plain semantic HTML (h1–h6, p, pre/code, ul/ol,
blockquote, table, plus the block components from step 4) — no required
class names, so it inherits your site's existing global styles/Tailwind
typography plugin for free in most cases. Two options:
- Fastest: also import
@inkform/framework/styles.cssand wrap the rendered post in<div className="fw-prose">. You get the framework's full typography/code-block/callout styling with zero CSS of your own, but it brings its own--fw-*design tokens (defaults are set, but for a matching look you'd override them — see anytemplates/*/app/theme.cssin the framework repo for the token list). - Better fit for an existing design system: skip the framework
stylesheet entirely and style the plain HTML output the way you already
style everything else on your site (a Tailwind
proseclass from@tailwindcss/typography, your own CSS, etc.).
8. Verify
npm run buildgenerateStaticParams prerenders every post at build time, so a successful
build means every post in content/blog/ compiled and rendered without
errors. Visit /blog and /blog/<slug> to confirm.
What you didn't need
No docs.json, no DocsShell, no sidebar, no search setup, no theme
tokens, no CLI scaffold. Those all exist for people building a complete docs
site on the framework — a blog bolted onto an existing site is a much
smaller slice of it: two loader functions and a renderer.