Metadata (title, description, keywords)
Product, category, and blog post entities each expose metaTitle, metaDescription, and metaKeywords as LocalizedText fields. Values are resolved at request time through i18nPageMetadataAsync in lib/seo.ts and returned via Next.js generateMetadata. When a tenant leaves a field blank, KOMERO falls back to the sensible default (product name / category name / blog title). Empty descriptions fall back to the first 160 characters of the entity description with entity references stripped.
// types/product.ts
export type ProductModel = {
metaTitle?: LocalizedText;
metaDescription?: LocalizedText;
metaKeywords?: LocalizedText;
canonicalOverride?: string;
slug: string;
isIndexable: boolean;
// ...
};
Canonical URL resolution
Canonical URLs are resolved in this order: product.canonicalOverride (validated URL) → siteConfig.seo.canonicalBaseUrl + path + locale → siteConfig.url + path + locale. Category and dynamic pages currently only support the tenant-wide base override; per-page override is on the roadmap.
// lib/seo.ts
function getBASE(seo: SEOConfig, siteUrl: string) {
return seo.canonicalBaseUrl?.trim() || siteUrl;
}
JSON-LD schemas
The storefront emits Product, Organization (with ContactPoint / PostalAddress / GeoCoordinates / OpeningHoursSpecification), BreadcrumbList, Website (with SearchAction), BlogPosting, ItemList, and FAQPage. AggregateRating structure is present in buildProductJsonLd but not populated until the ratings backend ships.
// lib/seo.ts
export function buildProductJsonLd(product, siteConfig, locale) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
name, description, image, brand,
offers: { '@type': 'Offer', price, priceCurrency, availability, url },
// aggregateRating?: { ratingValue, reviewCount } // ← ratings roadmap
};
}
Sitemap.xml
Generated in app/sitemap.ts with force-dynamic + revalidate = 3600. Per-host and per-tenant. Includes products (only when isIndexable is true), categories, brands, blog posts, blog categories, blog tags, dynamic pages, and static info pages. Product entries include an <image:image> child with the cover image URL. Invalid or pre-2000 sentinel dates are rejected.
// app/sitemap.ts
export const dynamic = 'force-dynamic';
export const revalidate = 3600;
export default async function sitemap() {
// fetch tenant, products, categories, blog...
return entries;
}
Robots.txt (AI crawler policy)
app/robots.ts is force-dynamic and per-host. Production hosts allow-list GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, and PerplexityBot for AI search visibility. Training-only crawlers (CCBot, anthropic-ai, Bytespider) are blocked. Staging hosts return Disallow: / for every user agent to protect draft storefronts from indexing.
// app/robots.ts (production)
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: CCBot
Disallow: /
Hreflang & x-default
buildI18nUrls returns { canonical, languages } for a path across every active tenant locale. x-default is set to the tenant's fallback locale (usually ka for Georgian merchants, en for international). Emitted by Next.js as <link rel='alternate' hreflang='...' href='...'>.
// lib/seo.ts
const { canonical, languages } = buildI18nUrls('/product/xyz', activeLocales, siteConfig);
return { alternates: { canonical, languages } };
Image alt text
ProductImageModel.altText is LocalizedText, so each active locale can have its own alt text per image. Managed in the admin ReviewImagesModal, which supports drag-to-reorder, cover selection, deletion + undo, paste upload, and image compression. Alt text is never auto-generated - tenants provide it manually.
// types/product.ts
export type ProductImageModel = {
imagePath: string;
altText: LocalizedText;
isCover: boolean;
order: number;
};
Analytics script injection
GA4, GTM, Meta Pixel, Hotjar, and Microsoft Clarity IDs are stored in tenant SEOConfig and injected via AnalyticsScripts.tsx. Every script is wrapped in a consent gate that respects the cookie consent categories (analytics vs marketing). Arbitrary <script> injection is deliberately not exposed for security and performance reasons.
// components/marketing/analytics/AnalyticsScripts.tsx
if (consent.analytics && seo.googleAnalyticsId) {
loadGA4(seo.googleAnalyticsId);
}
AI-generated metadata (Gemini)
The /api/ai/generate-seo endpoint calls Google Gemini via the @ai-sdk/google Vercel AI SDK to draft metaTitle, metaDescription, and metaKeywords for a product, category, or blog post based on the entity name, description, and locale. Drafts are inserted directly into the admin form for the tenant to review and save.
// api/ai/generate-seo
POST { entityType, entityId, locale }
=> { metaTitle, metaDescription, metaKeywords }