Overview
aSaaSin centralizes SEO in TinaCMS and maps it into Next.js Metadata. This page explains the SEO field in CMS, how metadata is generated for dynamic pages, when to hardcode static pages, and how the sitemap and robots files are produced.
SEO fields in TinaCMS
Each document defines a required seo object with:
- Basics:
title,description,author,keywords,robots - Open Graph:
locale,type,siteName,updatedTime,title,description,image { url, width, height, alt, type } - Article:
tag,section,publisher,publishedTime,modifiedTime
Field definitions live in /tina/fields/seoField.ts (options sourced from /tina/options).
description(both regular and Open Graph) must be ≤ 160 chars.- Prefer OG images at 1280 × 640, WebP format with meaningful alt.
From CMS → Next.js Metadata
For dynamic routes (e.g., blog posts), we fetch content by slug in generateMetadata and convert the seo object to a Next.js Metadata via generateSeoMetadata.
// app/blog/[slug]/page.tsx
export async function generateMetadata(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const post = await fetchPostBySlug(slug);
if (!post) return { title: 'Post not found' };
return generateSeoMetadata({ seo: post.seo, pathname: `blog/${slug}` });
}The helper lives in /lib/seo.ts and supports Open Graph and Article metadata if provided by the CMS. It also builds canonical URLs using NEXT_PUBLIC_APP_URL.
Set NEXT_PUBLIC_APP_URL (e.g. https://your-domain.com). It’s used to construct canonical URLs and sitemap entries.
Static pages
For static routes like Roadmap or Changelog overview, define export const metadata directly in the page file - simpler than wiring a CMS document just for metadata.
// app/roadmap/page.tsx (excerpt)
export const metadata = {
title: 'Roadmap | aSaaSin',
description: 'See planned, in-progress, and released features.',
openGraph: {
title: 'Roadmap | aSaaSin',
images: [{ url: '/saas-page-og-image.webp', width: 1280, height: 640, type: 'image/webp' }],
},
};Sitemap generation
The sitemap (app/sitemap.ts) merges a few static routes with CMS-driven routes from Tina (Pages, Posts, Docs). If you add new content areas (e.g., showcase/, customers/), extend the sitemap with another fetcher similar to the existing ones.
// app/sitemap.ts (excerpt)
export default async function sitemap() {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL!;
const [pages, posts, docs] = await Promise.all([
fetchPagesRoutes(baseUrl),
fetchPostsRoutes(baseUrl),
fetchDocsRoutes(baseUrl),
]);
const now = new Date().toISOString();
return [
{ url: `${baseUrl}/`, lastModified: now },
{ url: `${baseUrl}/roadmap`, lastModified: now },
{ url: `${baseUrl}/changelog`, lastModified: now },
...pages,
...posts,
...docs,
];
}How lastModified is chosen: fetchers use seo.openGraph.updatedTime if present; otherwise they fall back to the current time. See /services/page.ts, /services/post.ts, and /services/doc.ts.
When you add a new section: create fetch<Section>Routes(baseUrl) that queries Tina for slugs and returns { url, lastModified }[] (using openGraph.updatedTime when available). Include it in app/sitemap.ts alongside the other fetchers.
Robots.txt
app/robot.ts allows the site by default and disallows internal areas like /dashboard and /api/. It also points to the generated sitemap. Adjust the disallow list if your routes change.
// app/robot.ts (excerpt)
export default function robots() {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL!;
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/dashboard','/auth','/forgot-password','/reset-password','/sign-in','/sign-up','/admin','/api/'],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}Best practices
- Keep
seo.descriptionandseo.openGraph.description≤ 160 chars. - Use WebP 1280×640 for OG images; set descriptive
alt. - Update
openGraph.updatedTimewhenever content meaningfully changes. - For non-CMS static pages, hardcode
metadatain the page file. - Always set
NEXT_PUBLIC_APP_URLin every environment.