Skip to main content

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

ComponentDuration
MDX setup + content utilities0.5 day
Blog list + post routes0.5 day
Sitemap generation0.5 day
OG image generation0.5 day
Meta tags in layout0.5 day
Styling the blog0.5 day
Total~3 days

Find boilerplates with blog built-in on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments