SEO
Comprehensive SEO setup with metadata, dynamic OG images, sitemaps, structured data, and performance optimization.
By the end of this guide, you'll have implemented a complete SEO strategy for your SaaS application with dynamic metadata, Open Graph images, sitemaps, structured data, and performance optimizations.
Overview
The SaaS Boilerplate provides a comprehensive SEO system built on Next.js with Fumadocs integration, supporting dynamic metadata, Open Graph images, sitemaps, and structured data. Key features include:
- Dynamic metadata: Page-specific titles, descriptions, and Open Graph tags
- Open Graph images: Dynamic social media previews with custom branding
- Sitemap generation: Automatic XML sitemaps with proper priorities and change frequencies
- Robots.txt: Search engine crawling instructions
- Structured data: JSON-LD schema markup for rich search results
- Canonical URLs: Proper URL canonicalization to prevent duplicate content
- Performance optimization: Core Web Vitals and SEO-friendly loading
- PWA manifest: Progressive Web App metadata for mobile installation
- Documentation SEO: Fumadocs integration with optimized content structure
The system automatically generates SEO metadata while providing full customization capabilities for specific pages and content types.
Architecture
Next.js Metadata API
The foundation uses Next.js 15 Metadata API for comprehensive SEO control:
// src/app/layout.tsx - Root metadata
export const metadata: Metadata = {
metadataBase: new URL(AppConfig.url),
title: AppConfig.name,
openGraph: {
title: AppConfig.name,
url: AppConfig.url,
siteName: AppConfig.name,
images: [{
url: `${AppConfig.url}/og-image.png`,
width: 1200,
height: 630,
alt: AppConfig.name,
}],
},
}Dynamic Open Graph Images
Dynamic OG images are generated using Next.js edge runtime:
// src/app/og/docs/[...slug]/route.tsx
export async function GET(request: Request, { params }) {
const page = source.getPage(params.slug)
return new ImageResponse(
(
<DefaultImage
title={page.data.title}
description={page.data.description}
site="My App"
/>
),
{
width: 1200,
height: 630,
},
)
}Sitemap and Robots Integration
Automatic sitemap generation with proper SEO structure:
// src/app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${base}/`,
lastModified: now,
changeFrequency: 'weekly',
priority: 1,
},
// ... more entries
]
}Setting Up SEO
Configure Base Metadata
Set up root metadata in your layout:
// src/app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
title: {
default: 'Your SaaS App',
template: '%s | Your SaaS App'
},
description: 'Description of your SaaS application',
keywords: ['saas', 'productivity', 'collaboration'],
authors: [{ name: 'Your Company' }],
creator: 'Your Company',
publisher: 'Your Company',
openGraph: {
type: 'website',
locale: 'en_US',
url: process.env.NEXT_PUBLIC_APP_URL,
siteName: 'Your SaaS App',
images: [{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Your SaaS App',
}],
},
twitter: {
card: 'summary_large_image',
title: 'Your SaaS App',
description: 'Description of your SaaS application',
images: ['/og-image.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}Create OG Image Templates
Set up dynamic Open Graph image generation:
// src/app/og/route.tsx
import { ImageResponse } from 'next/og'
export async function GET() {
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0f0f0f',
fontSize: 32,
fontWeight: 600,
}}
>
<div style={{ color: '#ffffff' }}>Your SaaS App</div>
</div>
),
{
width: 1200,
height: 630,
},
)
}Configure Sitemap
Create a comprehensive sitemap:
// src/app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
// Static pages
const staticPages = [
{ url: '/', priority: 1, changeFrequency: 'weekly' },
{ url: '/pricing', priority: 0.8, changeFrequency: 'monthly' },
{ url: '/blog', priority: 0.7, changeFrequency: 'weekly' },
{ url: '/docs', priority: 0.7, changeFrequency: 'weekly' },
{ url: '/contact', priority: 0.5, changeFrequency: 'yearly' },
]
// Dynamic pages (fetch from database)
const dynamicPages = await getDynamicPages()
return [
...staticPages.map(page => ({
url: `${baseUrl}${page.url}`,
lastModified: new Date(),
changeFrequency: page.changeFrequency as any,
priority: page.priority,
})),
...dynamicPages
]
}Set Up Robots.txt
Configure search engine crawling rules:
// src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/private/'],
},
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
}
}Add Structured Data
Implement JSON-LD structured data:
// src/app/layout.tsx or specific pages
export const metadata: Metadata = {
other: {
'script:ld+json': JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Your SaaS App',
url: process.env.NEXT_PUBLIC_APP_URL,
logo: `${process.env.NEXT_PUBLIC_APP_URL}/logo.png`,
sameAs: [
'https://twitter.com/yourcompany',
'https://linkedin.com/company/yourcompany'
]
})
}
}Backend Usage (Procedures & Controllers)
Dynamic Metadata Generation
Generate metadata based on database content:
// Blog post page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await api.blogPosts.getById.query({ params: { id: params.id } })
if (!post.data) {
return {
title: 'Post Not Found'
}
}
return {
title: post.data.title,
description: post.data.excerpt,
openGraph: {
title: post.data.title,
description: post.data.excerpt,
images: [{
url: post.data.featuredImage,
width: 1200,
height: 630,
alt: post.data.title,
}],
},
twitter: {
card: 'summary_large_image',
title: post.data.title,
description: post.data.excerpt,
images: [post.data.featuredImage],
},
}
}SEO-Optimized Controllers
Create controllers that support SEO metadata:
// Blog controller with SEO support
export const blogController = igniter.controller({
name: 'Blog',
path: '/blog',
actions: {
getBySlug: igniter.query({
name: 'getBlogPost',
description: 'Get blog post by slug with SEO metadata',
method: 'GET',
path: '/:slug',
handler: async ({ context, request, response }) => {
const post = await context.database.blogPost.findUnique({
where: { slug: request.params.slug },
include: { author: true, tags: true }
})
if (!post) {
return response.notFound({ message: 'Post not found' })
}
// Add SEO metadata to response
return response.success(post, {
seo: {
title: post.title,
description: post.excerpt,
image: post.featuredImage,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
author: post.author.name,
tags: post.tags.map(tag => tag.name)
}
})
}
})
}
})Frontend Usage (Client-side)
Dynamic Page Metadata
Update metadata dynamically in client components:
// Client component with dynamic metadata
'use client'
import { useEffect } from 'react'
import Head from 'next/head'
function ProductPage({ product }: { product: Product }) {
useEffect(() => {
// Update document title
document.title = `${product.name} | Your SaaS App`
// Update meta description
const metaDescription = document.querySelector('meta[name="description"]')
if (metaDescription) {
metaDescription.setAttribute('content', product.description)
}
// Update Open Graph tags
updateMetaTag('og:title', product.name)
updateMetaTag('og:description', product.description)
updateMetaTag('og:image', product.image)
}, [product])
return <ProductDetails product={product} />
}
function updateMetaTag(property: string, content: string) {
let element = document.querySelector(`meta[property="${property}"]`)
if (!element) {
element = document.createElement('meta')
element.setAttribute('property', property)
document.head.appendChild(element)
}
element.setAttribute('content', content)
}SEO-Friendly Routing
Implement proper routing with SEO considerations:
// SEO-friendly navigation
function Navigation() {
const router = useRouter()
const handleNavigation = (href: string, title: string) => {
// Update page title immediately for better UX
document.title = `${title} | Your SaaS App`
router.push(href)
}
return (
<nav>
<Link
href="/products"
onClick={(e) => {
e.preventDefault()
handleNavigation('/products', 'Products')
}}
>
Products
</Link>
</nav>
)
}SEO Components and Features
Fumadocs SEO Integration
Documentation pages with automatic SEO:
// src/app/(site)/(content)/docs/[[...slug]]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const page = source.getPage(params.slug)
if (!page) {
return {
title: 'Page Not Found'
}
}
return {
title: page.data.title,
description: page.data.description,
openGraph: {
title: page.data.title,
description: page.data.description,
type: 'article',
images: [{
url: `/og/docs/${params.slug?.join('/') || 'index'}`,
width: 1200,
height: 630,
alt: page.data.title,
}],
},
}
}PWA Manifest
Progressive Web App metadata:
// src/app/manifest.ts
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Your SaaS App',
short_name: 'SaaS App',
description: 'A comprehensive SaaS application',
start_url: '/app',
display: 'standalone',
background_color: '#0f0f0f',
theme_color: '#0f0f0f',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}Structured Data Components
Reusable structured data components:
// components/seo/StructuredData.tsx
interface StructuredDataProps {
type: 'Article' | 'Product' | 'Organization' | 'WebSite'
data: Record<string, any>
}
export function StructuredData({ type, data }: StructuredDataProps) {
const structuredData = {
'@context': 'https://schema.org',
'@type': type,
...data
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData)
}}
/>
)
}
// Usage in pages
function BlogPost({ post }: { post: BlogPost }) {
return (
<>
<StructuredData
type="Article"
data={{
headline: post.title,
description: post.excerpt,
image: post.featuredImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name
}
}}
/>
<article>{/* Blog post content */}</article>
</>
)
}Practical Examples
Backend: E-commerce Product SEO
Complete product page SEO implementation:
// Product page with comprehensive SEO
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await api.products.getById.query({
params: { id: params.id }
})
if (!product.data) {
return {
title: 'Product Not Found'
}
}
return {
title: `${product.data.name} | Your SaaS App`,
description: product.data.description.substring(0, 160),
keywords: product.data.tags,
openGraph: {
title: product.data.name,
description: product.data.description,
images: [{
url: product.data.images[0],
width: 1200,
height: 630,
alt: product.data.name,
}],
type: 'product',
},
twitter: {
card: 'summary_large_image',
title: product.data.name,
description: product.data.description,
images: [product.data.images[0]],
},
other: {
'product:price:amount': product.data.price.toString(),
'product:price:currency': 'USD',
'product:availability': product.data.inStock ? 'in stock' : 'out of stock',
}
}
}
function ProductPage({ product }: { product: Product }) {
return (
<>
<StructuredData
type="Product"
data={{
name: product.name,
description: product.description,
image: product.images,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'
}
}}
/>
<div>{/* Product page content */}</div>
</>
)
}Frontend: Dynamic SEO Updates
Client-side SEO updates for SPAs:
// Custom hook for SEO management
function useSEO() {
const updateTitle = useCallback((title: string) => {
document.title = title
}, [])
const updateMeta = useCallback((name: string, content: string) => {
let meta = document.querySelector(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', name)
document.head.appendChild(meta)
}
meta.setAttribute('content', content)
}, [])
const updateOG = useCallback((property: string, content: string) => {
let meta = document.querySelector(`meta[property="${property}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('property', property)
document.head.appendChild(meta)
}
meta.setAttribute('content', content)
}, [])
const setPageSEO = useCallback((seo: {
title?: string
description?: string
image?: string
url?: string
}) => {
if (seo.title) {
updateTitle(seo.title)
updateOG('og:title', seo.title)
}
if (seo.description) {
updateMeta('description', seo.description)
updateOG('og:description', seo.description)
}
if (seo.image) {
updateOG('og:image', seo.image)
}
if (seo.url) {
updateOG('og:url', seo.url)
}
}, [updateTitle, updateMeta, updateOG])
return { setPageSEO, updateTitle, updateMeta, updateOG }
}
// Usage in components
function ProductDetail({ product }: { product: Product }) {
const { setPageSEO } = useSEO()
useEffect(() => {
setPageSEO({
title: `${product.name} | Your Store`,
description: product.description,
image: product.images[0],
url: `/products/${product.id}`
})
}, [product, setPageSEO])
return <ProductComponent product={product} />
}Backend: Blog SEO with Rich Snippets
Blog implementation with rich search results:
// Blog controller with SEO metadata
export const blogController = igniter.controller({
name: 'Blog',
path: '/blog',
actions: {
getBySlug: igniter.query({
name: 'getBlogPost',
description: 'Get blog post with SEO metadata',
method: 'GET',
path: '/:slug',
handler: async ({ context, request, response }) => {
const post = await context.database.blogPost.findUnique({
where: { slug: request.params.slug },
include: {
author: { select: { name: true, image: true } },
tags: { select: { name: true } },
category: { select: { name: true } }
}
})
if (!post) {
return response.notFound({ message: 'Post not found' })
}
// Generate SEO metadata
const seoMetadata = {
title: post.title,
description: post.excerpt || post.content.substring(0, 160),
image: post.featuredImage,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
author: post.author.name,
tags: post.tags.map(tag => tag.name),
category: post.category?.name,
wordCount: post.content.split(' ').length,
readingTime: Math.ceil(post.content.split(' ').length / 200)
}
return response.success({
post,
seo: seoMetadata
})
}
})
}
})
// Blog post page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const result = await api.blog.getBySlug.query({
params: { slug: params.slug }
})
if (!result.data) {
return { title: 'Post Not Found' }
}
const { post, seo } = result.data
return {
title: seo.title,
description: seo.description,
keywords: seo.tags,
authors: [{ name: seo.author }],
openGraph: {
title: seo.title,
description: seo.description,
images: [{
url: seo.image,
width: 1200,
height: 630,
alt: seo.title,
}],
type: 'article',
publishedTime: seo.publishedTime,
modifiedTime: seo.modifiedTime,
authors: [seo.author],
tags: seo.tags,
},
twitter: {
card: 'summary_large_image',
title: seo.title,
description: seo.description,
images: [seo.image],
},
other: {
'article:author': seo.author,
'article:published_time': seo.publishedTime,
'article:modified_time': seo.modifiedTime,
'article:tag': seo.tags,
}
}
}SEO Data Structure
Metadata API Types
Prop
Type
Sitemap Entry Structure
Prop
Type
Troubleshooting
Best Practices
See Also
- Content Layer - Fumadocs integration for documentation SEO
- Data Fetching - ISR and SSG for SEO optimization
- Authentication & Sessions - SEO considerations for protected content
- Performance and Security - SEO impact of performance
- Environment Variables - SEO configuration
API Reference
Next.js Metadata API
Prop
Type
SEO-Related Functions
Prop
Type
Open Graph Image Generation
Prop
Type
Structured Data Types
Prop
Type