How to Add Blog and SEO to Your SaaS Boilerplate (2026)
·StarterPick Team
blogseomdxguideboilerplate2026
TL;DR
Adding a blog to ShipFast or T3 Stack takes 2-3 days. The core: MDX content directory, dynamic routes, meta tags, sitemap.xml, and OG images. For boilerplates that include blog (Makerkit, Supastarter, Open SaaS), you configure rather than build. This guide covers adding blog + SEO from scratch to any Next.js boilerplate.
Step 1: MDX Setup
npm install @next/mdx @mdx-js/loader @mdx-js/react rehype-highlight remark-gfm gray-matter
// next.config.js
const withMDX = require('@next/mdx')({
extension: /\.mdx?$/,
options: {
remarkPlugins: [require('remark-gfm')],
rehypePlugins: [require('rehype-highlight')],
},
});
module.exports = withMDX({
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
});
Step 2: Blog Content Structure
content/
└── blog/
├── first-post.mdx
├── product-update-march-2026.mdx
└── feature-announcement.mdx
---
title: "Post Title"
description: "150-160 character meta description with target keyword"
date: "2026-03-08"
author: "Team Name"
tags: ["tag1", "tag2"]
image: "/blog/first-post-cover.jpg"
---
# Post Title
Content goes here in markdown.
Step 3: Blog List and Post Routes
// lib/blog.ts — content loading utilities
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const BLOG_DIR = path.join(process.cwd(), 'content/blog');
export type BlogPost = {
slug: string;
title: string;
description: string;
date: string;
author: string;
tags: string[];
image?: string;
content: string;
};
export function getBlogPosts(): BlogPost[] {
const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.mdx'));
return files
.map(file => {
const slug = file.replace('.mdx', '');
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
const { data, content } = matter(raw);
return { slug, content, ...data } as BlogPost;
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getBlogPost(slug: string): BlogPost | null {
const filepath = path.join(BLOG_DIR, `${slug}.mdx`);
if (!fs.existsSync(filepath)) return null;
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
return { slug, content, ...data } as BlogPost;
}
// app/blog/page.tsx — blog list
import { getBlogPosts } from '~/lib/blog';
import { PostCard } from '~/components/blog/PostCard';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog — YourSaaS',
description: 'Insights on [your topic] from the YourSaaS team',
};
export default function BlogPage() {
const posts = getBlogPosts();
return (
<main>
<h1>Blog</h1>
<div className="grid gap-6">
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</div>
</main>
);
}
// app/blog/[slug]/page.tsx — individual post
import { getBlogPost, getBlogPosts } from '~/lib/blog';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
return getBlogPosts().map(post => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = getBlogPost(params.slug);
if (!post) return {};
return {
title: `${post.title} — YourSaaS`,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
},
};
}
export default function BlogPostPage({ params }: { params: { slug: string } }) {
const post = getBlogPost(params.slug);
if (!post) notFound();
return (
<article>
<header>
<h1>{post.title}</h1>
<time>{new Date(post.date).toLocaleDateString()}</time>
</header>
<MDXRemote source={post.content} />
</article>
);
}
Step 4: Sitemap Generation
// app/sitemap.ts — dynamic sitemap
import { MetadataRoute } from 'next';
import { getBlogPosts } from '~/lib/blog';
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getBlogPosts();
const baseUrl = 'https://yoursaas.com';
const staticRoutes = [
{ url: baseUrl, changeFrequency: 'weekly', priority: 1.0 },
{ url: `${baseUrl}/pricing`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/blog`, changeFrequency: 'weekly', priority: 0.7 },
{ url: `${baseUrl}/about`, changeFrequency: 'monthly', priority: 0.5 },
] as MetadataRoute.Sitemap;
const blogRoutes: MetadataRoute.Sitemap = posts.map(post => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly',
priority: 0.6,
}));
return [...staticRoutes, ...blogRoutes];
}
Step 5: OG Image Generation
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getBlogPost } from '~/lib/blog';
export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OGImage({ params }: { params: { slug: string } }) {
const post = getBlogPost(params.slug);
if (!post) return new Response('Not found', { status: 404 });
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '60px',
}}
>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 24 }}>
YourSaaS Blog
</div>
<div style={{ color: 'white', fontSize: 64, fontWeight: 'bold', lineHeight: 1.1 }}>
{post.title}
</div>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 24 }}>
{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
),
{ ...size }
);
}
Step 6: Root Layout Meta Tags
// app/layout.tsx — global SEO defaults
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://yoursaas.com'),
title: {
default: 'YourSaaS — What You Do',
template: '%s | YourSaaS',
},
description: 'Default meta description (150-160 characters, includes keyword)',
robots: { index: true, follow: true },
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://yoursaas.com',
siteName: 'YourSaaS',
},
twitter: {
card: 'summary_large_image',
site: '@yoursaas',
creator: '@yourname',
},
};
Time Budget
| Component | Duration |
|---|---|
| MDX setup + content utilities | 0.5 day |
| Blog list + post routes | 0.5 day |
| Sitemap generation | 0.5 day |
| OG image generation | 0.5 day |
| Meta tags in layout | 0.5 day |
| Styling the blog | 0.5 day |
| Total | ~3 days |
Find boilerplates with blog built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →