npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 adapter

Source 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_DESCRIPTION is 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).
  • page is 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 code fallback.

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-hydrated

Precedence merge

mergeSeoBlocks(...layers) takes layers low → high; later layers win, but only for fields that carry real signalundefined, "", 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 resolvePageSEO

This 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/v1 prefix, shared by all storefront-config reads). Merchant id: NEXT_PUBLIC_MERCHANT_ID, sent as the gk-merchant-id header.
  • Runtime updates: merchant edits → seo.updated webhook → 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 SEOfetchPageAuthoredSeo(path) is a stub returning {}. Implement it as mapRemoteConfigToSeoBlock(await fetchSeoConfig(pageKey(path))) when the editor ships; every page route gains it with no call-site change.
  • Multi-tenant — merchant identity is NEXT_PUBLIC_MERCHANT_ID today; 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_ID are 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