SEO
Sitemap, robots, JSON-LD structured data, and metadata out of the box.
The boilerplate ships a complete, zero-config SEO pack: a per-locale XML sitemap, a robots.txt, schema.org JSON-LD structured data (Organization site-wide, Product on detail pages), and full page metadata with Open Graph and Twitter tags. Every value is sourced from a single site configuration file, so rebranding the store automatically updates the SEO surface.
The three moving parts you customize are all in src/config/site.ts: siteConfig.url, siteConfig.name, and siteConfig.description. Set url to your production origin before deploying — it is the base for canonical URLs, sitemap entries, JSON-LD, and metadataBase.
What's included
| Surface | Served at | Source file |
|---|---|---|
| XML sitemap | /sitemap.xml | src/app/sitemap.ts |
| Robots rules | /robots.txt | src/app/robots.ts |
| Organization JSON-LD | Every page (root layout) | src/components/seo/OrganizationJsonLd.tsx |
| Product JSON-LD | Product detail pages | src/components/seo/ProductJsonLd.tsx |
| Metadata / OG / Twitter | Every page (metadata export) | src/app/[locale]/layout.tsx |
Metadata
The root layout at src/app/[locale]/layout.tsx exports a Next.js Metadata object. Because there is no app/layout.tsx, this [locale] layout is the root layout, so its metadata applies to every route unless a page overrides it.
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url),
title: {
default: siteConfig.name,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
applicationName: siteConfig.name,
keywords: ["Ecommerce", "Boilerplate", "Next.js", "TypeScript", "Firebase"],
openGraph: {
title: siteConfig.name,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.name,
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.name,
description: siteConfig.description,
},
robots: "index, follow",
icons: {
icon: "/favicon.ico",
},
};Key behaviors:
metadataBaseis set tonew URL(siteConfig.url). This turns every relative URL in metadata (Open Graph images, canonical links) into an absolute URL, which search engines and social scrapers require.title.templateis`%s | ${siteConfig.name}`. A page that sets its own title as"Wireless Headphones"rendersWireless Headphones | Acme Store; pages that set no title fall back totitle.default(justsiteConfig.name).- Open Graph emits
og:title,og:description,og:url,og:site_name, andog:type(website). - Twitter uses the
summary_large_imagecard with title and description. robots: "index, follow"renders<meta name="robots" content="index, follow">on every page. This is the page-level meta tag and is separate from the site-widerobots.txtdescribed below.applicationName,keywords, and the favicon (/favicon.ico) round out the tags.
The <html lang> attribute is set per request from the resolved locale (<html lang={locale}>), so each localized route advertises the correct language to crawlers. See internationalization for how locales resolve.
Sitemap
src/app/sitemap.ts implements Next.js's file-based sitemap. It is served at /sitemap.xml and regenerated at most once per hour:
export const revalidate = 3600;
/** Maximum number of products to include in the sitemap. */
const PRODUCT_LIMIT = 500;What the sitemap includes
The sitemap combines three sources. Because every storefront route is locale-prefixed (/{locale}/...), it emits one entry per configured locale for each path, and each entry carries languages alternates pointing at the per-locale variants of the same page.
1. Static storefront routes
| Path | changeFrequency | priority |
|---|---|---|
/ (home) | daily | 1 |
/products (catalog) | daily | 0.9 |
/about | monthly | 0.5 |
/login | yearly | 0.3 |
each entry in siteConfig.legalNav | yearly | 0.2 |
Out of the box legalNav covers /privacy-policy, /terms-of-service, and /cookie-policy.
2. Category listing URLs — one per entry in productCategories, as query-string URLs of the form /products?category=<slug> (query-string URLs are valid sitemap entries), with changeFrequency: "daily" and priority: 0.8.
3. Product detail URLs — fetched through the commerce layer via commerce.products.list(PRODUCT_LIMIT) (up to 500 products), emitted as /products/<product.id> with changeFrequency: "weekly" and priority: 0.7.
The product fetch is best-effort. Sitemap generation can run at build time (for example in CI without backend credentials); if commerce.products.list throws, the catch block ships the static and category entries instead of failing the build. See the commerce layer for backend configuration.
URL shape and alternates
Locale prefixing is handled by a small helper — the home path maps to /{locale} (no trailing segment), everything else to /{locale}{path}:
const localizedUrl = (locale: string, path: string): string =>
path === "" ? `${base}/${locale}` : `${base}/${locale}${path}`;With the shipped locales (en, es) and siteConfig.url of https://example.com, the home page produces two entries — https://example.com/en and https://example.com/es — each listing both as xhtml:link language alternates.
robots.txt
src/app/robots.ts is served at /robots.txt. It allows crawlers across the storefront while keeping admin, account, and API routes out of the index, and points crawlers at the sitemap:
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/*/admin", "/*/account", "/api"],
},
],
sitemap: `${siteConfig.url}/sitemap.xml`,
};
}The disallow patterns use a /*/ wildcard because the admin and account areas live under a locale prefix (/{locale}/admin, /{locale}/account), so a single pattern covers every locale. The /api prefix keeps route handlers out of search results.
Structured data (JSON-LD)
Both structured-data components are server components that render a single <script type="application/ld+json"> tag. Each escapes < to < when serializing, so a value containing </script> cannot break out of the tag (script-injection defense).
Organization (site-wide)
OrganizationJsonLd is rendered once in the root layout (<OrganizationJsonLd /> in src/app/[locale]/layout.tsx), so every page carries the store's identity. All values come from siteConfig:
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Acme Store",
"url": "https://example.com",
"logo": "https://example.com/logo.svg",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "customer service",
"email": "hello@example.com",
"telephone": "+1 (555) 000-0000"
},
"sameAs": ["https://www.linkedin.com/company/your-company", "https://www.instagram.com/your-company"]
}name,urlcome fromsiteConfig.name/siteConfig.url.logois`${siteConfig.url}/logo.svg`.contactPointreadssiteConfig.contact.emailandsiteConfig.contact.phonewith a fixedcontactTypeof"customer service".sameAsisObject.values(siteConfig.links)— every social link you configure.
Product (detail pages)
ProductJsonLd is rendered on the product detail page (src/app/[locale]/products/[id]/page.tsx) as <ProductJsonLd product={product} />. It reads a structural subset of the catalog product, so a full Product can be passed directly:
| Schema field | Source | Notes |
|---|---|---|
name | product.productName | always emitted |
sku | product.id | always emitted |
brand.name | product.brand | wrapped in { "@type": "Brand" } |
description | product.description | only when present |
image | product.primaryImageURL | made absolute against siteConfig.url if it starts with / |
offers | derived | only when price is a number |
The offers block is only added when product.price is a number:
{
"@type": "Offer",
"priceCurrency": "USD",
"price": 199,
"availability": "https://schema.org/InStock",
"url": "https://example.com/en/products/<product-id>"
}availabilityis derived fromstock:stock === 0yieldshttps://schema.org/OutOfStock; any other value — including untracked inventory — yieldshttps://schema.org/InStock.urlis the canonical, default-locale variant of the product page:`${siteConfig.url}/${defaultLocale}/products/${product.id}`. Per-locale variants are declared as sitemap alternates rather than duplicated here.priceCurrencyis fixed to"USD"in the schema type.
Add ProductJsonLd to any custom product surface yourself — it is not automatic. Import it and render it in the page tree: import ProductJsonLd from "@/components/seo/ProductJsonLd".
Configuration
Everything above derives from siteConfig in src/config/site.ts. The fields that drive SEO:
export const siteConfig = {
name: "Acme Store",
description:
"A modular and scalable ecommerce boilerplate built with Next.js, TypeScript, Tailwind CSS, shadcn/ui, and Firebase.",
url: "https://example.com",
// ...
links: {
linkedin: "https://www.linkedin.com/company/your-company",
instagram: "https://www.instagram.com/your-company",
},
legalNav: [
{ label: "Privacy Policy", href: "/privacy-policy" },
{ label: "Terms of Service", href: "/terms-of-service" },
{ label: "Cookie Policy", href: "/cookie-policy" },
],
// ...
} as const;siteConfig.url to your production origin (for example https://store.acme.com). This becomes metadataBase, the sitemap/robots base, and every JSON-LD and canonical URL.siteConfig.name and siteConfig.description — they feed the title template, meta description, Open Graph, and Twitter tags.siteConfig.contact (email, phone) and siteConfig.links (social profiles) so the Organization structured data is accurate.public/logo.svg and public/favicon.ico so the Organization logo and favicon resolve.No environment variables are required for the SEO pack — it works entirely from static config. Only siteConfig.url must reflect your real domain for absolute URLs to be correct. See environment variables for the optional Stripe, Resend, and SQL integrations.