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.description
andseo.openGraph.description
≤ 160 chars. - Use WebP 1280×640 for OG images; set descriptive
alt
. - Update
openGraph.updatedTime
whenever content meaningfully changes. - For non-CMS static pages, hardcode
metadata
in the page file. - Always set
NEXT_PUBLIC_APP_URL
in every environment.