saasprokit
SEO

SEO

Site metadata, JSON-LD structured data, sitemap, and robots.txt — as actually implemented in @repo/seo

@repo/seo provides site-wide metadata, JSON-LD structured data, and is wired into apps/web's root layout, sitemap.ts, and robots.ts.

Site config

SiteConfig describes the site's identity. It is a plain value passed explicitly to every helper — there is no module-level state and nothing to initialise before use:

// packages/seo/metadata.ts
export interface SiteConfig {
  name: string;
  description: string;
  url: string;
  ogImage?: string;
  locale?: string;
  twitterHandle?: string;
  author?: { name: string; url?: string };
  links?: { twitter?: string; github?: string };
}

The web app declares one in apps/web/lib/seo-config.ts:

import type { SiteConfig } from "@repo/seo";
import { env } from "@/lib/env";

export const siteConfig: SiteConfig = {
  name: env.NEXT_PUBLIC_APP_NAME,
  description: `${env.NEXT_PUBLIC_APP_NAME} — multi-organization SaaS platform.`,
  url: env.NEXT_PUBLIC_APP_URL,
  ogImage: "/og-image.png",
  locale: "en_US",
};

Identity comes entirely from NEXT_PUBLIC_APP_NAME and NEXT_PUBLIC_APP_URL — the optional twitterHandle, author, and links fields are left unset, since the app has no per-customer values to put there.

Metadata

createMetadata(properties, config) builds a Next.js Metadata object: title, description, Open Graph, Twitter card, robots (full index/follow + Googlebot directives), and a canonical alternate. Anything you pass in properties is deep-merged over those defaults, so a page can override any field without restating the rest.

Omit title and you get the site-wide template (%s | AppName); pass one and you get Title | AppName. Pass image to override the Open Graph image for a single page.

The root layout uses it with no title, so every page inherits the template:

// apps/web/app/[locale]/layout.tsx
export const metadata = createMetadata(
  { description: siteConfig.description },
  siteConfig
);

A page supplies its own:

export const generateMetadata = async (): Promise<Metadata> =>
  createMetadata({ title: "Billing", description: "Manage your subscription." }, siteConfig);

Structured data (JSON-LD)

The JsonLd component renders an escaped application/ld+json script tag. Exactly two schema generators exist today, both rendered in the root layout:

import { generateOrganizationSchema, generateWebSiteSchema, JsonLd } from "@repo/seo";

<JsonLd code={generateOrganizationSchema(siteConfig)} />
<JsonLd code={generateWebSiteSchema(siteConfig)} />
  • generateOrganizationSchema(config)Organization schema from the config you pass (name, url, logo, a generic ContactPoint). sameAs reads links.twitter/links.github, but the app's siteConfig sets no links, so it's always an empty array.
  • generateWebSiteSchema(config)WebSite schema with a SearchAction pointing at /search?q={search_term_string}.

The JSON-LD SearchAction targets /search, but the app has no /search route. Remove or replace that potentialAction before relying on sitelinks search box rich results.

To add another schema type (Article, FAQPage, etc.), add a generator function to packages/seo/schemas.tsx following the same pattern — typed against schema-dts, taking a SiteConfig argument — and render it with the same JsonLd component. None of these exist yet; add them when you actually have the page type to back them (e.g. don't add Product schema without product pages).

Sitemap and robots

apps/web/app/sitemap.ts currently returns a single static entry for the homepage — there's no dynamic route enumeration (no blog/CMS in this app to enumerate). apps/web/app/robots.ts disallows /api/, /auth/, /user/, /_next/, /private/ for all crawlers plus a Googlebot-specific rule, and points sitemap/host at siteConfig.url.

/private/ is listed in robots.ts but unused — the app has no /private route.

Validating

On this page