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)—Organizationschema from the config you pass (name, url, logo, a genericContactPoint).sameAsreadslinks.twitter/links.github, but the app'ssiteConfigsets nolinks, so it's always an empty array.generateWebSiteSchema(config)—WebSiteschema with aSearchActionpointing 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
- Rich Results Test — checks the Organization/WebSite JSON-LD renders correctly
- PageSpeed Insights — Core Web Vitals
- Google Search Console — indexing status, sitemap submission