tps://schema.org/OutOfStock',
'https://schema.org/PreOrder',
'https://schema.org/LimitedAvailability'
]);
const ConditionEnum = z.enum([
'https://schema.org/NewCondition',
'https://schema.org/UsedCondition',
'https://schema.org/RefurbishedCondition'
]);
const OfferSchema = z.object({
'@type': z.literal('Offer'),
price: z.string().regex(/^\d+(.\d{1,2})?$/),
priceCurrency: z.string().length(3),
availability: AvailabilityEnum,
itemCondition: ConditionEnum,
priceValidUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
url: z.string().url()
});
const AggregateRatingSchema = z.object({
'@type': z.literal('AggregateRating'),
ratingValue: z.string().regex(/^\d+(.\d+)?$/),
reviewCount: z.string().regex(/^\d+$/),
bestRating: z.literal('5'),
worstRating: z.literal('1')
});
const ProductSchema = z.object({
'@context': z.literal('https://schema.org'),
'@type': z.literal('Product'),
name: z.string().min(1).max(250),
description: z.string().min(10).max(5000),
image: z.array(z.string().url()).min(1).max(5),
sku: z.string(),
brand: z.object({ '@type': z.literal('Brand'), name: z.string() }),
offers: OfferSchema,
aggregateRating: AggregateRatingSchema.optional()
});
type ProductSchemaInput = z.infer<typeof ProductSchema>;
### Step 2: Build a Type-Safe Generator
Separate schema construction from UI rendering. This allows unit testing, caching, and consistent field mapping across routes.
```typescript
export function buildProductSchema(product: ProductSchemaInput): string {
const validation = ProductSchema.safeParse(product);
if (!validation.success) {
console.error('Schema validation failed:', validation.error.flatten());
return '';
}
return JSON.stringify(validation.data, null, 2);
}
Step 3: Server-Side Injection in Next.js App Router
JSON-LD must exist in the initial HTML payload. Next.js App Router handles this cleanly using the Script component with strategy="beforeInteractive" or direct inline injection within the page component.
// app/products/[slug]/page.tsx
import { buildProductSchema } from '@/utils/schema-builder';
import { getProductData } from '@/lib/api';
export default async function ProductRoute({ params }: { params: { slug: string } }) {
const rawProduct = await getProductData(params.slug);
const schemaPayload = buildProductSchema({
'@context': 'https://schema.org',
'@type': 'Product',
name: rawProduct.title,
description: rawProduct.summary,
image: rawProduct.assets.map(asset => asset.url),
sku: rawProduct.inventoryCode,
brand: { '@type': 'Brand', name: rawProduct.manufacturer },
offers: {
'@type': 'Offer',
price: rawProduct.pricing.current.toFixed(2),
priceCurrency: rawProduct.pricing.currency,
availability: rawProduct.inventory.status === 'available'
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
itemCondition: 'https://schema.org/NewCondition',
priceValidUntil: '2026-12-31',
url: `https://catalog.example.com/products/${params.slug}`
},
aggregateRating: rawProduct.reviews.count > 0 ? {
'@type': 'AggregateRating',
ratingValue: rawProduct.reviews.average.toFixed(1),
reviewCount: rawProduct.reviews.count.toString(),
bestRating: '5',
worstRating: '1'
} : undefined
});
return (
<main className="product-layout">
{schemaPayload && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: schemaPayload }}
/>
)}
{/* Product UI components */}
</main>
);
}
Architecture Decisions & Rationale
- Server-Side Generation: Guarantees first-wave crawl success. Googlebot parses the initial HTML before JavaScript execution. Client-side hydration introduces race conditions that frequently drop structured data.
- Zod Validation: Schema.org fields are notoriously permissive. Runtime validation catches type mismatches (e.g., numeric prices sent as numbers instead of strings) before serialization. This prevents silent rich result disqualification.
- Separation of Concerns: The
buildProductSchema utility isolates structured data logic from presentation components. This enables independent testing, caching strategies, and reuse across PDP, category, and search routes.
- Conditional Rating Injection:
aggregateRating is omitted when review data is absent. Google penalizes pages with empty or placeholder rating fields. Omitting the field is safer than injecting invalid data.
Pitfall Guide
Structured data failures are rarely loud. They manifest as missing rich results in Search Console, not runtime errors. The following pitfalls account for the majority of production issues.
1. Client-Side Injection Dependency
Explanation: Injecting JSON-LD via useEffect or client-only frameworks relies on Googlebot's second crawl wave. Resource throttling frequently prevents execution, resulting in zero rich result eligibility.
Fix: Always generate and inject schema during server rendering or static generation. Verify presence using curl -s <url> | grep application/ld+json.
2. Numeric Price Serialization
Explanation: Schema.org requires price and priceCurrency as strings. Passing JavaScript numbers (349.00) causes validation failures in strict parsers and triggers rich result warnings.
Fix: Explicitly cast to string using .toFixed(2) or template literals before injection.
3. Availability URL Hardcoding
Explanation: Using plain text like "InStock" or "Available" violates schema.org specifications. Only full URLs (https://schema.org/InStock) are recognized.
Fix: Maintain a centralized enum mapping inventory states to schema.org URLs. Validate against the enum during build time.
4. AggregateOffer vs. Offer Array Confusion
Explanation: AggregateOffer signals a price range across variants. An array of Offer objects signals distinct purchasable SKUs. Mixing them causes Google to suppress pricing displays.
Fix: Use AggregateOffer only when variants share a single PDP and prices fluctuate. Use an Offer array when each variant has a distinct URL, SKU, and inventory state.
5. Image Dimension Violations
Explanation: Google requires product images to be at least 160×90px and no larger than 1920×1080px. Serving thumbnails or ultra-high-res assets triggers image suppression in rich results.
Fix: Implement an image transformation pipeline that crops and resizes assets to the acceptable range before injecting URLs into schema.
6. Duplicate or Nested Schema Blocks
Explanation: Injecting multiple Product types on a single page, or nesting Product inside WebPage incorrectly, confuses the parser. Google will ignore the entire block.
Fix: Enforce a single Product schema per PDP. Use @id references if cross-linking to Organization or BreadcrumbList schemas.
7. Ignoring priceValidUntil Expiration
Explanation: Leaving priceValidUntil as a static future date indefinitely violates Google's pricing accuracy policies. Expired dates trigger manual reviews and rich result removal.
Fix: Dynamically calculate priceValidUntil based on promotional campaign end dates or set it to 30–90 days from deployment. Automate rotation via cron jobs or edge middleware.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single PDP with size/color variants sharing inventory | AggregateOffer | Signals price range without fragmenting crawl budget | Low (single payload) |
| Distinct SKUs with separate inventory & URLs | Array of Offer objects | Preserves variant-level accuracy for Shopping tab sync | Medium (larger payload) |
| High-traffic catalog with frequent price changes | Edge-rendered JSON-LD + CDN caching | Reduces origin load while maintaining first-wave crawl reliability | Medium (CDN egress) |
| Legacy SPA with no SSR capability | react-helmet-async + pre-rendering service | Bridges crawl gap until migration to SSR/SSG | High (third-party dependency) |
| Multi-currency storefront | Dynamic priceCurrency mapping per locale | Prevents currency mismatch penalties and Shopping tab rejections | Low (configuration overhead) |
Configuration Template
Copy this TypeScript utility into your project to standardize schema generation across routes. It includes validation, variant handling, and safe serialization.
// utils/product-schema.ts
import { z } from 'zod';
const SchemaContext = z.literal('https://schema.org');
const ProductType = z.literal('Product');
const OfferType = z.literal('Offer');
const RatingType = z.literal('AggregateRating');
const ProductSchema = z.object({
'@context': SchemaContext,
'@type': ProductType,
name: z.string().min(1),
description: z.string().min(10),
image: z.array(z.string().url()),
sku: z.string(),
brand: z.object({ '@type': z.literal('Brand'), name: z.string() }),
offers: z.union([
z.object({
'@type': OfferType,
price: z.string(),
priceCurrency: z.string().length(3),
availability: z.string().url(),
itemCondition: z.string().url(),
priceValidUntil: z.string(),
url: z.string().url()
}),
z.array(z.object({
'@type': OfferType,
name: z.string(),
price: z.string(),
priceCurrency: z.string().length(3),
availability: z.string().url()
}))
]),
aggregateRating: z.object({
'@type': RatingType,
ratingValue: z.string(),
reviewCount: z.string(),
bestRating: z.literal('5'),
worstRating: z.literal('1')
}).optional()
});
export type ProductSchemaPayload = z.infer<typeof ProductSchema>;
export function generateProductJSONLD(payload: ProductSchemaPayload): string {
const result = ProductSchema.safeParse(payload);
if (!result.success) {
console.warn('Product schema validation skipped due to malformed input.');
return '';
}
return JSON.stringify(result.data, null, 2);
}
Quick Start Guide
- Install validation dependency: Run
npm install zod to enable runtime schema checking.
- Create the utility: Save the configuration template above as
utils/product-schema.ts.
- Map your product data: Align your database or CMS fields to the
ProductSchemaPayload interface. Ensure prices are strings, availability uses schema.org URLs, and images meet dimension constraints.
- Inject server-side: In your product page route, call
generateProductJSONLD() with mapped data and render the output inside a <script type="application/ld+json"> tag.
- Verify crawl visibility: Use
curl -s <your-product-url> | grep application/ld+json to confirm the block exists in the initial HTML. Submit the URL to Google Search Console's URL Inspection tool and request indexing.
Structured data is not a ranking lever; it is a visibility multiplier. When engineered with type safety, server-side injection, and strict field validation, it transforms flat search listings into high-conversion entry points without increasing ad spend or compromising page performance.