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 and seo.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.