@shopkit/seo
v1.1.2
Published
Comprehensive SEO system for e-commerce with structured data, meta tags, Open Graph, Twitter Cards, sitemap generation, and breadcrumb management
Readme
@shopkit/seo
Comprehensive SEO system for e-commerce applications with structured data (JSON-LD), meta tags, Open Graph, Twitter Cards, sitemap generation, and breadcrumb management.
Features
- Resolver functions —
resolvePageSEOandresolveCommercePageSEOfor one-linegenerateMetadatain Next.js App Router pages (v1.1.0+) - Structured Data - JSON-LD schema generation for products, collections, organizations
- Meta Tags - Automatic meta tag generation and optimization
- Open Graph - Facebook/social media sharing optimization
- Twitter Cards - Twitter-specific card markup
- Sitemap Generation - Dynamic XML sitemap creation
- Breadcrumbs - SEO-friendly breadcrumb trails
- Caching - Built-in SEO data caching
- Validation - Schema.org validation utilities
Installation
npm install @shopkit/seo
# or
bun add @shopkit/seoQuick Start
Resolver Functions (v1.1.0+ — recommended for Next.js App Router)
The simplest way to add SEO to any page. Reads NEXT_PUBLIC_SITE_NAME and NEXT_PUBLIC_BASE_URL from env vars automatically.
// app/products/[handle]/page.tsx
import { resolveCommercePageSEO } from '@shopkit/seo';
import type { Metadata } from 'next';
export async function generateMetadata({ params }: { params: { handle: string } }): Promise<Metadata> {
const product = await fetchProduct(params.handle);
return resolveCommercePageSEO('product', product, `/products/${params.handle}`);
}// app/collections/[collectionname]/page.tsx
export async function generateMetadata({
params,
searchParams,
}: {
params: { collectionname: string };
searchParams?: Record<string, string>;
}): Promise<Metadata> {
const collection = await fetchCollection(params.collectionname);
// searchParams enables automatic noindex on ?sort_by=, ?filter.*, ?page= URLs
return resolveCommercePageSEO('collection', collection, `/collections/${params.collectionname}`, { searchParams });
}// app/page.tsx (home) — reads siteName/siteUrl from env vars
export async function generateMetadata(): Promise<Metadata> {
return resolveCommercePageSEO('home', null, '/');
}When your backend stores home page SEO (so merchants can edit it without a deploy):
// app/page.tsx (home) — SEO editable from merchant editor, no deploy needed
import { resolvePageSEO } from '@shopkit/seo';
export async function generateMetadata(): Promise<Metadata> {
const { siteUrl, siteName } = getSEOConfig();
const page = await fetchPage('home'); // GET /pages/home from your backend
return resolvePageSEO(page.seo, { siteUrl, siteName, path: '/', isHomePage: true });
}For editor-created and CMS pages under /pages/[handle] (Shopify pattern):
// app/pages/[handle]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { siteUrl, siteName } = getSEOConfig();
const page = await fetchPage(params.handle); // GET /pages/:handle from your backend
return resolvePageSEO(page.seo, { siteUrl, siteName, path: `/pages/${params.handle}` });
}// app/search/page.tsx — noIndex page
import { resolvePageSEO } from '@shopkit/seo';
import { getSEOConfig } from '@/config';
export async function generateMetadata(): Promise<Metadata> {
const { siteUrl, siteName } = getSEOConfig();
return resolvePageSEO({ title: 'Search', noIndex: true }, { siteUrl, siteName, path: '/search' });
}Basic Setup (lower-level API)
Use SEOFactory and SEOManager directly for more control over configuration:
Generate Product SEO
import { SEOManager } from '@shopkit/seo';
const seoManager = new SEOManager({
siteName: 'My Store',
siteUrl: 'https://mystore.com',
defaultLanguage: 'en',
enableStructuredData: true,
enableOpenGraph: true,
enableTwitterCards: true,
enableCaching: false,
});
// Generate product SEO data
const productSEO = seoManager.generateProductSEO({
title: 'Premium T-Shirt',
description: 'High-quality cotton t-shirt',
product: {
id: 'product-123',
name: 'Premium T-Shirt',
description: 'High-quality cotton t-shirt',
price: 29.99,
currency: 'USD',
availability: 'InStock',
brand: 'MyBrand',
images: [{ url: 'https://example.com/image.jpg' }],
},
});Generate Meta Tags
import { MetaTagGenerator } from '@shopkit/seo';
const metaGenerator = new MetaTagGenerator({
siteName: 'My Store',
siteUrl: 'https://mystore.com',
});
const metaTags = metaGenerator.generateNextJSMetadata({
title: 'Product Page',
description: 'Amazing product description',
canonical: 'https://mystore.com/products/example',
});Generate Structured Data
import { StructuredDataGenerator } from '@shopkit/seo';
const structuredData = new StructuredDataGenerator();
// Product schema
const productSchema = structuredData.generateProductSchema({
id: 'product-123',
name: 'Premium T-Shirt',
description: 'High-quality cotton t-shirt',
price: 29.99,
currency: 'USD',
availability: 'InStock',
brand: 'MyBrand',
images: [{ url: 'https://example.com/image.jpg' }],
});
// Organization schema
const orgSchema = structuredData.generateOrganizationSchema({
name: 'My Store',
url: 'https://mystore.com',
logo: 'https://mystore.com/logo.png',
});Generate Breadcrumbs
import { BreadcrumbGenerator } from '@shopkit/seo';
const breadcrumbGenerator = new BreadcrumbGenerator({
siteUrl: 'https://mystore.com',
});
const breadcrumbs = breadcrumbGenerator.generateBreadcrumbs('/products/t-shirts');
// Returns BreadcrumbItem[] — pass to generateBreadcrumbJsonLd for JSON-LD outputGenerate Sitemap
import { SitemapGenerator } from '@shopkit/seo';
const sitemapGenerator = new SitemapGenerator({
siteUrl: 'https://mystore.com',
});
const sitemap = sitemapGenerator.generateSitemap([
{ url: '/', changeFrequency: 'daily', priority: 1.0 },
{ url: '/products', changeFrequency: 'daily', priority: 0.9 },
{ url: '/products/t-shirt', changeFrequency: 'weekly', priority: 0.8 },
]);API Reference
resolvePageSEO (v1.1.0+)
The permanent, stable resolver. Pure synchronous. Returns Next.js Metadata given a typed SEO block and rendering context.
function resolvePageSEO(
seoBlock: ApiSeoBlock | null | undefined,
context: ResolvedSEOContext
): MetadataApiSeoBlock — all fields optional; pass null for full site defaults:
interface ApiSeoBlock {
title?: string; // Raw title — siteName suffix applied automatically
description?: string;
keywords?: string[];
canonicalOverride?: string; // Full URL; hostname must match context.siteUrl
noIndex?: boolean; // Default false
noFollow?: boolean; // Default false
ogImage?: { url: string; width?: number; height?: number; altText?: string };
structuredData?: StructuredDataSchema[];
}ResolvedSEOContext — rendering context:
interface ResolvedSEOContext {
siteUrl: string; // e.g. "https://mystore.com" — no trailing slash
siteName: string;
path: string; // e.g. "/products/blue-hat" or "/"
locale?: string;
isHomePage?: boolean; // Inferred from path === "/" if omitted
enableOpenGraph?: boolean; // Default true
enableTwitterCards?: boolean; // Default true
}Title rules (applied in order):
- Empty/whitespace title →
siteName - Home page → title as-is (no suffix)
- Title already contains
siteName→ no change - Otherwise →
"{title} | {siteName}"
Canonical rules: Path is stripped of query strings, fragments, and trailing slashes. Home page canonical = siteUrl. canonicalOverride is validated — mismatched hostname falls back to derived canonical with a console warning.
resolveCommercePageSEO (v1.1.0+)
Extraction layer for Next.js App Router pages that fetch commerce data directly. Reads NEXT_PUBLIC_SITE_NAME and NEXT_PUBLIC_BASE_URL from env vars, adapts commerce data into ApiSeoBlock, generates structured data, and delegates to resolvePageSEO.
async function resolveCommercePageSEO(
type: 'product' | 'collection' | 'home' | 'static',
commerceData: any | null,
path: string,
options?: ResolveCommercePageSEOOptions
): Promise<Metadata>
interface ResolveCommercePageSEOOptions {
searchParams?: Record<string, string | string[] | undefined>;
canonicalPath?: string;
}Null data behavior:
type: "product"or"collection"withnulldata →noIndex: true(do not index a 404)type: "home"or"static"withnulldata → site defaults, indexable
Filtered/sorted collection noindex: When type is "collection" and options.searchParams contains any of sort_by, filter.*, or page, the page is automatically noindexed. Always pass searchParams on collection pages:
export async function generateMetadata({ params, searchParams }) {
const collection = await fetchCollection(params.collectionname);
return resolveCommercePageSEO('collection', collection, `/collections/${params.collectionname}`, { searchParams });
}Canonical override for nested routes: Use options.canonicalPath when the same product is reachable at multiple URLs (e.g. /collections/[c]/products/[h]). The path is resolved against siteUrl automatically:
// /collections/[c]/products/[h]/page.tsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.handle);
return resolveCommercePageSEO(
'product',
product,
`/collections/${params.collectionname}/products/${params.handle}`,
{ canonicalPath: `/products/${params.handle}` }
);
}Data field fallback chain:
- Title:
data.seo_title→data.seo?.title→data.title - Description:
data.seo_description→data.seo?.description→data.description(HTML stripped) - Image:
data.images?.[0]?.url→data.image?.url→data.images?.[0]?.src - og:image fallback:
NEXT_PUBLIC_DEFAULT_IMAGEenv var when no product/collection image available
Lifecycle note: This function stays permanently for products and collections — it handles the data extraction that the commerce API doesn't pre-format (
seo_title → seo.title → titlefallback chains, HTML stripping, null-data noindex). It is only replaced if your backend explicitly starts returning pre-builtApiSeoBlockdata alongside product/collection responses.For editor-created pages (
/pages/[handle]), callresolvePageSEOdirectly with theApiSeoBlockreturned by your backend. For the home page, when the backend stores it as a page with handle"home", useresolvePageSEO(page.seo, { ..., isHomePage: true })— no deploy needed to change home page SEO.
SEOFactory
Factory for creating SEO system instances.
// Create with custom config
SEOFactory.createSEOSystem(config?: Partial<SEOConfig>): SEOManager
// Create for environment
SEOFactory.createForEnvironment(env: 'development' | 'staging' | 'production'): SEOManagerSEOManager
Main SEO orchestration class.
class SEOManager {
constructor(config: Partial<SEOConfig>)
generateProductSEO(data: ProductSEOData): SEOResult
generateCollectionSEO(data: CollectionSEOData): SEOResult
generatePageSEO(data: SEOData): SEOResult
validate(data: SEOData): SEOValidationResult
}StructuredDataGenerator
Generate JSON-LD structured data schemas.
class StructuredDataGenerator {
async generateProductSchema(product: any): Promise<StructuredDataSchema>
async generateCollectionSchema(collection: any): Promise<StructuredDataSchema>
async generateOrganizationSchema(data?: any): Promise<StructuredDataSchema>
async generateBreadcrumbSchema(items: BreadcrumbItem[]): Promise<StructuredDataSchema | null>
async generateWebsiteSchema(): Promise<StructuredDataSchema>
}MetaTagGenerator
Generate Next.js metadata or raw HTML meta tags.
class MetaTagGenerator {
constructor(config: MetaTagConfig)
generateNextJSMetadata(seoData: SEOData): Metadata // used by resolvePageSEO
async generateMetaTags(seoData: SEOData): Promise<MetaTagData>
generateHTMLMetaTags(seoData: SEOData): string
}BreadcrumbGenerator
Generate breadcrumb trails.
class BreadcrumbGenerator {
constructor(config: { siteUrl: string })
generateBreadcrumbs(path: string, customItems?: BreadcrumbItem[]): BreadcrumbItem[]
generateProductBreadcrumbs(product: any): BreadcrumbItem[]
generateCollectionBreadcrumbs(collection: any): BreadcrumbItem[]
generateBlogBreadcrumbs(post: any): BreadcrumbItem[]
generateBreadcrumbJsonLd(breadcrumbs: BreadcrumbItem[]): string
}SitemapGenerator
Generate XML sitemaps.
class SitemapGenerator {
constructor(config: { siteUrl: string })
generateSitemap(entries: SitemapEntry[]): string
generateSitemapIndex(sitemaps: string[]): string
generateProductEntries(products: any[]): SitemapEntry[]
generateCollectionEntries(collections: any[]): SitemapEntry[]
}SEOCacheManager
Cache SEO data for performance.
class SEOCacheManager {
constructor(options: SEOCacheOptions)
get(key: string): SEOData | null
set(key: string, data: SEOData): void
invalidate(key: string): void
invalidateByTag(tag: string): void
}Types
SEOConfig
interface SEOConfig {
siteName: string;
siteUrl: string;
defaultLanguage: string;
defaultTitle?: string;
defaultDescription?: string;
defaultImage?: string;
enableStructuredData: boolean;
enableOpenGraph: boolean;
enableTwitterCards: boolean;
enableCaching: boolean;
enableValidation?: boolean;
enableCompression?: boolean;
cacheConfig?: SEOCacheOptions;
socialMedia?: SocialMediaConfig;
}SEOData
interface SEOData {
title: string;
description?: string;
keywords?: string[];
canonical?: string;
image?: string | ImageObject;
type?: 'website' | 'article' | 'product' | 'profile';
locale?: string;
alternateLanguages?: AlternateLanguage[];
structuredData?: StructuredDataSchema[];
breadcrumbs?: BreadcrumbItem[];
noIndex?: boolean;
noFollow?: boolean;
}ProductSEOData
interface ProductSEOData extends SEOData {
product: {
id: string;
name: string;
description: string;
price: number;
currency: string;
availability: 'InStock' | 'OutOfStock' | 'PreOrder';
brand?: string;
category?: string;
sku?: string;
images?: ProductImage[];
reviews?: ProductReviews;
};
}SitemapEntry
interface SitemapEntry {
url: string;
lastModified?: Date;
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
alternateLanguages?: AlternateLanguage[];
}Sub-path Exports
The package supports granular imports for tree-shaking:
// Main exports (includes resolver functions)
import { resolvePageSEO, resolveCommercePageSEO } from '@shopkit/seo';
import { SEOFactory, SEOManager } from '@shopkit/seo';
// Resolver functions only (smallest import)
import { resolvePageSEO, resolveCommercePageSEO } from '@shopkit/seo/resolvers';
import type { ApiSeoBlock, ResolvedSEOContext, ResolveCommercePageSEOOptions } from '@shopkit/seo/resolvers';
// Specific modules
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';
import { SEOUtils } from '@shopkit/seo/utils';
import { SchemaValidator } from '@shopkit/seo';
import { getSEOEnv } from '@shopkit/seo/config';Environment Variables
# Required
NEXT_PUBLIC_SITE_NAME="My Store"
NEXT_PUBLIC_BASE_URL="https://mystore.com"
# Optional
NEXT_PUBLIC_DEFAULT_IMAGE="https://mystore.com/og-image.jpg" # og:image fallback for pages with no product/collection image
SEO_ENABLE_STRUCTURED_DATA="true"
SEO_ENABLE_OPEN_GRAPH="true"
SEO_ENABLE_TWITTER_CARDS="true"
SEO_ENABLE_CACHING="true"Next.js Integration
App Router (Metadata API) — v1.1.0+ resolver pattern
// app/products/[handle]/page.tsx
import { resolveCommercePageSEO } from '@shopkit/seo';
import type { Metadata } from 'next';
export async function generateMetadata({ params }: { params: { handle: string } }): Promise<Metadata> {
const product = await fetchProduct(params.handle);
// null product → noIndex automatically (no try/catch needed)
return resolveCommercePageSEO('product', product, `/products/${params.handle}`);
}// app/collections/[collectionname]/page.tsx
export async function generateMetadata({
params,
searchParams,
}: {
params: { collectionname: string };
searchParams?: Record<string, string>;
}): Promise<Metadata> {
const collection = await fetchCollection(params.collectionname);
return resolveCommercePageSEO('collection', collection, `/collections/${params.collectionname}`, { searchParams });
}// app/page.tsx (home)
export async function generateMetadata(): Promise<Metadata> {
return resolveCommercePageSEO('home', null, '/');
}Sitemap Route
Note: The example below uses
getCommerceClient()from@/config/commerce, which is the storefront-starter app-level helper. Replace it with however your app creates a commerce client. The pattern —Promise.allSettled+.dataunwrap fromIResponse<T>— is what matters.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getCommerceClient } from '@/config/commerce'; // replace with your commerce client
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const siteUrl = (process.env.NEXT_PUBLIC_BASE_URL || '').replace(/\/$/, '');
const client = getCommerceClient();
// Promise.allSettled so a commerce outage never 500s the sitemap endpoint
const [productsResult, collectionsResult] = await Promise.allSettled([
client.getProducts({ first: 250 }),
client.getCollections({ first: 100 }),
]);
const products = productsResult.status === 'fulfilled' ? (productsResult.value?.data ?? []) : [];
const collections = collectionsResult.status === 'fulfilled' ? (collectionsResult.value?.data ?? []) : [];
return [
{ url: siteUrl, changeFrequency: 'daily', priority: 1.0 },
...products.map((p: any) => ({
url: `${siteUrl}/products/${p.handle}`,
lastModified: p.updatedAt ? new Date(p.updatedAt) : new Date(),
changeFrequency: 'daily' as const,
priority: 0.8,
})),
...collections.map((c: any) => ({
url: `${siteUrl}/collections/${c.handle}`,
changeFrequency: 'weekly' as const,
priority: 0.7,
})),
];
}Robots Route
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const siteUrl = (process.env.NEXT_PUBLIC_BASE_URL || '').replace(/\/$/, '');
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/account', '/orders', '/pages/orders', '/cart', '/checkout', '/api/', '/search'],
},
sitemap: `${siteUrl}/sitemap.xml`,
};
}License
MIT
