@shopkit/seo
v1.2.0
Published
Comprehensive SEO system for e-commerce with structured data, meta tags, Open Graph, Twitter Cards, sitemap generation, and breadcrumb management
Readme
@shopkit/seo
SEO for the storefront platform: one function per page produces a complete Next.js
Metadata object — title, description, canonical, Open Graph, Twitter, robots, and JSON-LD —
sourced automatically from the right place (admin config, commerce catalog, or the editor)
with resilient caching and zero per-merchant boilerplate.
getPageMetadata()— the single, stable entry point every page calls.- Admin-managed SEO — merchants edit home/site SEO in the Admin Panel; it goes live at runtime via the Next.js Data Cache + webhook revalidation, with no deploy and no repo edits.
- Future-proof — new capabilities (Open Graph, per-page editor SEO, robots, …) land inside this package; the merchant-facing function never changes.
Table of contents
- Mental model
- Architecture
- Source ownership (who owns each page's SEO)
- Request flow
- Precedence merge
- Caching & invalidation
- Quick start
- Admin SEO config integration
- Failure handling
- Extending it (the one seam)
- API reference
- Environment variables
- Sub-path exports
- Sitemap & robots
- Testing
Mental model
A page's SEO is decided in three separated steps — keep them apart and the system never needs a redesign:
1. SOURCE — WHERE the values come from (admin config, catalog data, editor, code, env)
2. MERGE — HOW sources combine (a fixed precedence; empty values never clobber)
3. RENDER — turn the merged block into a Next.js Metadata object (pure, stable)Merchants only ever touch the facade (getPageMetadata). Everything below it is internal
and free to evolve.
Architecture
┌───────────────────────────────────────────────────────────────────────────┐
│ MERCHANT-FACING CONTRACT (stable — the name/shape never changes) │
│ getPageMetadata({ pageType, path, data?, fallback? }) → Promise<Metadata>│
├───────────────────────────────────────────────────────────────────────────┤
│ ORCHESTRATION (new capabilities land here) │
│ • select sources by pageType │
│ • mergeSeoBlocks(...) — precedence, skips empty/undefined │
│ • buildStructuredData(...) — JSON-LD per page type │
│ • canonical / noindex / og:image fallback │
├───────────────────────────────────────────────────────────────────────────┤
│ SOURCES (each returns Partial<ApiSeoBlock>, empties omitted) │
│ fetchDefaultSeoConfig() → mapRemoteConfigToSeoBlock() [admin config] │
│ extractCommerceSeo(data) [commerce catalog] │
│ fetchPageAuthoredSeo(path) [editor — STUB] │
│ fallback (caller-supplied) · env defaults │
├───────────────────────────────────────────────────────────────────────────┤
│ PURE RENDER (stable, no I/O) │
│ resolvePageSEO(block, ctx) → Next.js Metadata │
│ (title rules · canonical · Open Graph · Twitter · robots · JSON-LD) │
└───────────────────────────────────────────────────────────────────────────┘
Module map:
metadata/get-page-metadata.ts the facade + orchestrator
metadata/merge.ts mergeSeoBlocks (precedence, skip-empty)
config/remote/seo-config-client fetchSeoConfig / fetchDefaultSeoConfig (server-intended)
config/remote/map-remote-config admin envelope → ApiSeoBlock (THE extension seam)
config/remote/seo-config-schema Zod schema for the admin envelope
config/remote/page-authored-seo editor per-page source (STUB today)
resolvers/commerce-extract extractCommerceSeo + JSON-LD builders
resolvers/api-resolver resolvePageSEO — the pure renderer
resolvers/commerce-resolver resolveCommercePageSEO — backward-compat adapterSource ownership
Each page type has exactly one primary source. fallback (caller code) and env defaults are
the universal safety layers beneath it.
| pageType | Primary source | Editor per-page? |
| --------------------- | ------------------------------------------------ | ---------------- |
| home | admin default config (fetchDefaultSeoConfig) | no |
| product | commerce catalog data (data you pass in) | never |
| collection | commerce catalog data (data you pass in) | never |
| page | editor per-page config (stub) + fallback | yes (its source) |
Notes:
- Home comes from the admin config only (it has no catalog data). Env
NEXT_PUBLIC_SITE_DESCRIPTIONis the last-resort description floor. - Product / collection come strictly from the catalog object — never the admin/home default and never the editor. A missing/unique description is deliberately left empty (Google generates a better snippet than a generic tagline).
pageis the generic bucket for every non-home, non-commerce route — content pages (about-us, policies) and utility pages (orders). The editor source is path-keyed, so a page the merchant configured picks up editor SEO while an unconfigured one falls back to its codefallback.
Request flow
User → GET / (or /products/x, /collections/y, /about-us …)
│
▼
Next.js route → generateMetadata() runs server-side (SSG / ISR / SSR)
│ return getPageMetadata({ pageType, path, data?, fallback? })
▼
getPageMetadata() [@shopkit/seo facade]
│
├─ resolveSeoBlock(pageType) ── pick sources, merge by precedence:
│ home → [ env floor ← fallback ← adminConfig ]
│ product → [ fallback ← commerceData ]
│ collection → [ fallback ← commerceData ]
│ page → [ env floor ← fallback ← editor(stub) ]
│ │
│ └─ fetchSeoConfig(...) : Data Cache HIT (<300s) → cached, no network
│ MISS → fetch gokwik (gk-merchant-id, 2s timeout)
│ → Zod parse → cache w/ tags
│ any failure → null → layer skipped
│
├─ buildStructuredData(pageType) → WebSite+Org / Product / CollectionPage JSON-LD
├─ canonical override · filter-noindex · og:image fallback
└─ resolvePageSEO(block, ctx) → Next.js Metadata (pure render)
▼
Next.js renders <head> into the INITIAL HTML ← crawler-safe, never client-hydratedPrecedence merge
mergeSeoBlocks(...layers) takes layers low → high; later layers win, but only for fields
that carry real signal — undefined, "", and [] never overwrite a lower value (an explicit
false does win). Every source omits empty fields, so this is a clean field-by-field merge.
home: env-description-floor < fallback < admin config (admin wins)
product/collection: fallback < commerce data (catalog wins)
page: env-description-floor < fallback < editor (stub) (editor wins)
└── lowest ──────────────────────────── highest ──┘
title's site-name floor is applied last, inside resolvePageSEOThis is why migration is zero-risk: pass your current hardcoded values as fallback and they hold
until a real source (admin/editor) provides the field — then they quietly step aside.
Caching & invalidation
Time-based: fetchSeoConfig uses next: { revalidate: 300, tags: [...] }
→ warm cache = no network; cold = one 2s-bounded fetch.
→ the config's 300s TTL is INDEPENDENT of any page's own `revalidate`.
Event-based: admin edits SEO → backend webhook →
apps/*/api/webhooks/cache-invalidation
revalidateTag("seo-config") → live on next render, zero deploy
Cache tags emitted per fetch:
"seo-config", "seo-config:{merchantId}", "seo-config:{merchantId}:{key}"On a revalidation failure, Next keeps serving the last good cached value (stale-while-revalidate) — that plus the env-var fallback is the retry story (no explicit retries).
Quick start
Every page's generateMetadata calls one function. pageType selects the source; the input is
a discriminated union (the compiler requires data on product/collection and forbids it on home).
// app/page.tsx — home (admin-driven, zero config)
import { getPageMetadata } from "@shopkit/seo";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata({ pageType: "home", path: "/" });
}// app/products/[handle]/page.tsx — product (from catalog data)
export async function generateMetadata(props: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await props.params;
const product = await fetchProduct(handle); // null → noindex automatically
return getPageMetadata({ pageType: "product", path: `/products/${handle}`, data: product });
}// app/collections/[collectionname]/page.tsx — collection (+ filter noindex)
export async function generateMetadata(props: {
params: Promise<{ collectionname: string }>;
searchParams?: Promise<Record<string, string>>;
}): Promise<Metadata> {
const { collectionname } = await props.params;
const searchParams = await props.searchParams; // ?sort_by= / ?filter.* / ?page= → noindex
const collection = await fetchCollection(collectionname);
return getPageMetadata({
pageType: "collection",
path: `/collections/${collectionname}`,
data: collection,
searchParams,
});
}// app/collections/[c]/products/[h]/page.tsx — variant route, consolidate canonical
return getPageMetadata({
pageType: "product",
path: `/collections/${c}/products/${h}`,
data: product,
canonicalPath: `/products/${h}`, // dedupe duplicate content
});// app/about-us/page.tsx — content/utility page (editor-ready; fallback holds today)
export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata({ pageType: "page", path: "/about-us", fallback: { title: "About Us" } });
}
// app/orders/page.tsx — utility page, not indexed
return getPageMetadata({ pageType: "page", path: "/orders", fallback: { title: "Order Confirmation", noIndex: true } });getPageMetadata reads getSEOConfig() internally — pages never plumb siteUrl/siteName themselves.
Admin SEO config integration
The Admin Panel exposes a versioned default SEO config. The package fetches it for home pages:
curl 'https://api-gw-v4.dev.gokwik.io/qa/pi/pc/api/v1/storefront/configs/seo/default' \
--header 'gk-merchant-id: <MERCHANT_ID>'{ "domain": "seo", "key": "default",
"value": { "meta_title": "…", "meta_description": "…" },
"version": 7 }Wiring:
- Base URL:
NEXT_PUBLIC_STOREFRONT_API_URL(the full gateway/api/v1prefix, shared by all storefront-config reads). Merchant id:NEXT_PUBLIC_MERCHANT_ID, sent as thegk-merchant-idheader. - Runtime updates: merchant edits →
seo.updatedwebhook →revalidateTag("seo-config")→ live on the next render, no deploy. - Environment (build-time):
NEXT_PUBLIC_*vars are inlined at build — they must be set when the app is built, not only at runtime.
Failure handling
fetchSeoConfig returns null on any failure and never throws into a render:
| Scenario | Behavior |
| ------------------------------- | ----------------------------------------------------------- |
| Missing env vars | null → source skipped → fallback / env defaults render |
| Non-2xx / error body / bad shape| null (Zod safeParse) → same fallback |
| Timeout (2s AbortSignal) | null → same fallback; a hung upstream can't pin the render|
| Warm cache, revalidation fails | Next serves the last good cached value (stale-while-revalidate) |
The env chain (NEXT_PUBLIC_SITE_NAME, NEXT_PUBLIC_SITE_DESCRIPTION) is the guaranteed floor —
there is always a valid title/description even during a total API outage.
Extending it (the one seam)
New admin/config fields grow through one function: mapRemoteConfigToSeoBlock
(config/remote/map-remote-config.ts). The Zod schema's value is .passthrough(), so new backend
fields flow through untouched — you only add a mapping line. The merchant contract, the fetcher, and
the renderer stay frozen. New sources (e.g. the editor) plug in via their own function (below).
// Adding a new admin field is a one-liner here — nothing else changes:
if (typeof v.og_image === "string" && v.og_image.trim()) {
block.ogImage = { url: v.og_image.trim(), altText: v.og_image_alt, width: v.og_image_width, height: v.og_image_height };
}
// FUTURE: twitter overrides, canonical, robots, structured data — same pattern.Roadmap seams already wired:
og_image— schema + mapper ready. Ships the moment the backend sends it, no release needed. (Must be an absolute http(s) PNG/JPG — the generator drops relative URLs, and social scrapers ignore SVG.)- Editor per-page SEO —
fetchPageAuthoredSeo(path)is a stub returning{}. Implement it asmapRemoteConfigToSeoBlock(await fetchSeoConfig(pageKey(path)))when the editor ships; everypageroute gains it with no call-site change. - Multi-tenant — merchant identity is
NEXT_PUBLIC_MERCHANT_IDtoday; per-request resolution is a package-internal change later (cache tags are already per-merchant).
API reference
getPageMetadata(input) — primary
type GetPageMetadataInput =
| { pageType: "home"; path: string; fallback?: Partial<ApiSeoBlock> }
| { pageType: "product" | "collection"; path: string; data: unknown;
fallback?: Partial<ApiSeoBlock>;
searchParams?: Record<string, string | string[] | undefined>; // collection filter noindex
canonicalPath?: string } // variant-route consolidation
| { pageType: "page"; path: string; fallback?: Partial<ApiSeoBlock> };
function getPageMetadata(input: GetPageMetadataInput): Promise<Metadata>;fallback fills only fields no real source provided (it never overrides them) — the migration-safe
default. For a hard, code-controlled override that bypasses all sources, use resolvePageSEO directly.
resolvePageSEO(block, context) — pure renderer / escape hatch
Synchronous, no I/O. ApiSeoBlock → Next.js Metadata. Applies title rules (siteName suffix on
non-home), canonical normalization + host-checked override, robots, OG/Twitter, and JSON-LD injection.
Use directly only when you want to bypass the source pipeline (fully hardcoded SEO).
resolveCommercePageSEO(type, data, path, options?) — backward-compat adapter
Thin wrapper that maps to getPageMetadata. Retained so existing repos keep working; prefer
getPageMetadata in new code. type: "static" maps to the page bucket.
Low-level (advanced composition)
fetchSeoConfig(key)/fetchDefaultSeoConfig()— server-intended, cached, null-safe config reads.mapRemoteConfigToSeoBlock(config)— pure envelope →Partial<ApiSeoBlock>.RemoteSeoConfig— the parsed envelope type.
Generators (unchanged, lower-level)
StructuredDataGenerator, MetaTagGenerator, BreadcrumbGenerator, SitemapGenerator,
SEOManager / SEOFactory, SEOUtils, SchemaValidator. See source for signatures.
ApiSeoBlock
interface ApiSeoBlock {
title?: string; // siteName suffix applied automatically (except home)
description?: string;
keywords?: string[];
canonicalOverride?: string; // full URL; hostname must match siteUrl
noIndex?: boolean;
noFollow?: boolean;
ogImage?: { url: string; width?: number; height?: number; altText?: string };
structuredData?: StructuredDataSchema[];
}Environment variables
# Required
NEXT_PUBLIC_SITE_NAME="My Store"
NEXT_PUBLIC_BASE_URL="https://mystore.com" # no trailing slash
# Admin SEO config (home page) — one generic gateway base for all storefront-config reads
NEXT_PUBLIC_STOREFRONT_API_URL="https://api-gw-v4.dev.gokwik.io/qa/pi/pc/api/v1" # set at BUILD time
NEXT_PUBLIC_MERCHANT_ID="<merchant-id>"
# Optional fallbacks
NEXT_PUBLIC_SITE_DESCRIPTION="…" # description floor for home/page
NEXT_PUBLIC_DEFAULT_IMAGE="https://mystore.com/og.png" # og:image fallback (absolute PNG/JPG)
# Feature flags (default on)
SEO_ENABLE_OPEN_GRAPH="true"
SEO_ENABLE_TWITTER_CARDS="true"At runtime, if
NEXT_PUBLIC_STOREFRONT_API_URL/NEXT_PUBLIC_MERCHANT_IDare unset, admin SEO is simply skipped and pages fall back to env defaults — no crash (graceful degradation).
Build-time guard — assertSeoEnv()
A missing config var is a developer mistake, not a runtime condition — so fail the build, don't
degrade. Call assertSeoEnv() from your next.config to block the build with one clear, aggregated
message listing every missing required key. The package owns which keys are required, so a future
required key is enforced on the next @shopkit/seo bump with no app edit.
// next.config.js (or your config bootstrap)
const { assertSeoEnv } = require("@shopkit/seo/config");
// Set SKIP_ENV_VALIDATION=1 to bypass (lint-only CI, or a Docker stage without runtime env).
if (!process.env.SKIP_ENV_VALIDATION) assertSeoEnv();Required keys today: NEXT_PUBLIC_SITE_NAME, NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_STOREFRONT_API_URL,
NEXT_PUBLIC_MERCHANT_ID. This is the fail-fast counterpart to the runtime resilience above — together
they mean "you can't forget to configure it, but a live outage never breaks a render."
Sub-path exports
import { getPageMetadata } from "@shopkit/seo"; // primary
import { resolvePageSEO, resolveCommercePageSEO } from "@shopkit/seo"; // lower-level / compat
import { fetchSeoConfig, mapRemoteConfigToSeoBlock } from "@shopkit/seo";// advanced
import { assertSeoEnv } from "@shopkit/seo/config"; // build-time env guard
import { MetaTagGenerator } from "@shopkit/seo/meta-tags";
import { StructuredDataGenerator } from "@shopkit/seo/structured-data";
import { SitemapGenerator } from "@shopkit/seo/sitemap";
import { BreadcrumbGenerator } from "@shopkit/seo/breadcrumbs";Sitemap & robots
app/sitemap.ts and app/robots.ts are app-level route handlers (they use your commerce client).
SitemapGenerator helps build entries; the resilient pattern is Promise.allSettled so a commerce
outage never 500s the sitemap:
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const siteUrl = (process.env.NEXT_PUBLIC_BASE_URL || "").replace(/\/$/, "");
const client = getCommerceClient(); // your app's commerce client
const [products, collections] = await Promise.allSettled([
client.getProducts({ first: 250 }),
client.getCollections({ first: 100 }),
]);
// …map fulfilled results to entries…
return [{ url: siteUrl, changeFrequency: "daily", priority: 1.0 }, /* … */];
}// app/robots.ts
export default function robots(): MetadataRoute.Robots {
const siteUrl = (process.env.NEXT_PUBLIC_BASE_URL || "").replace(/\/$/, "");
return {
rules: { userAgent: "*", allow: "/", disallow: ["/account", "/orders", "/cart", "/checkout", "/api/", "/search"] },
sitemap: `${siteUrl}/sitemap.xml`,
};
}Testing
bun run test # vitest
bun run type-check # tsc --noEmit
bun run build # tsup (ESM + CJS + d.ts)Coverage spans the facade + per-page-type source selection, the precedence merge (skip-empty), the
config client's full failure matrix, the mapper (incl. the og_image seam), and the backward-compat
adapter (JSON-LD, breadcrumbs, canonicalPath, filter-noindex, null-data noindex).
License
MIT
