Acme Store Docs
Features

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

SurfaceServed atSource file
XML sitemap/sitemap.xmlsrc/app/sitemap.ts
Robots rules/robots.txtsrc/app/robots.ts
Organization JSON-LDEvery page (root layout)src/components/seo/OrganizationJsonLd.tsx
Product JSON-LDProduct detail pagessrc/components/seo/ProductJsonLd.tsx
Metadata / OG / TwitterEvery 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.

src/app/[locale]/layout.tsx
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:

  • metadataBase is set to new 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.template is `%s | ${siteConfig.name}`. A page that sets its own title as "Wireless Headphones" renders Wireless Headphones | Acme Store; pages that set no title fall back to title.default (just siteConfig.name).
  • Open Graph emits og:title, og:description, og:url, og:site_name, and og:type (website).
  • Twitter uses the summary_large_image card 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-wide robots.txt described 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:

src/app/sitemap.ts
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

PathchangeFrequencypriority
/ (home)daily1
/products (catalog)daily0.9
/aboutmonthly0.5
/loginyearly0.3
each entry in siteConfig.legalNavyearly0.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}:

src/app/sitemap.ts
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:

src/app/robots.ts
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:

Organization JSON-LD (example output)
{
  "@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, url come from siteConfig.name / siteConfig.url.
  • logo is `${siteConfig.url}/logo.svg`.
  • contactPoint reads siteConfig.contact.email and siteConfig.contact.phone with a fixed contactType of "customer service".
  • sameAs is Object.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 fieldSourceNotes
nameproduct.productNamealways emitted
skuproduct.idalways emitted
brand.nameproduct.brandwrapped in { "@type": "Brand" }
descriptionproduct.descriptiononly when present
imageproduct.primaryImageURLmade absolute against siteConfig.url if it starts with /
offersderivedonly when price is a number

The offers block is only added when product.price is a number:

Product JSON-LD offers block
{
  "@type": "Offer",
  "priceCurrency": "USD",
  "price": 199,
  "availability": "https://schema.org/InStock",
  "url": "https://example.com/en/products/<product-id>"
}
  • availability is derived from stock: stock === 0 yields https://schema.org/OutOfStock; any other value — including untracked inventory — yields https://schema.org/InStock.
  • url is 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.
  • priceCurrency is 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:

src/config/site.ts
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;
Set 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.
Set siteConfig.name and siteConfig.description — they feed the title template, meta description, Open Graph, and Twitter tags.
Fill in siteConfig.contact (email, phone) and siteConfig.links (social profiles) so the Organization structured data is accurate.
Add 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.

On this page