Kevin Green
Developer at Sanity
Surface product age to help editors choose the freshest inventory
// Existing schema types
defineField({
name: 'modules',
type: 'array',
description: 'Editorial modules to associate with this collection',
of: [
defineArrayMember({type: 'grid'}),
// Additional types can be added
],
group: 'editorial',
}),
defineField({
name: 'productAgeAnalysis',
title: 'Product Age Analysis',
type: 'array',
of: [
{
type: 'object',
fields: [
{
name: 'product',
title: 'Product',
type: 'reference',
to: [{type: 'product'}],
},
{
name: 'ageInDays',
title: 'Age in Days',
type: 'number',
},
{
name: 'updatedAgeInDays',
title: 'Days Since Last Update',
type: 'number',
},
{
name: 'isOld',
title: 'Is Stale Product',
type: 'boolean',
},
{
name: 'createdAt',
title: 'Created At',
type: 'datetime',
},
{
name: 'lastUpdated',
title: 'Last Updated',
type: 'datetime',
},
],
preview: {
select: {
title: 'product.title',
productTitle: 'product.store.title',
ageInDays: 'ageInDays',
updatedAgeInDays: 'updatedAgeInDays',
isOld: 'isOld',
createdAt: 'createdAt',
lastUpdated: 'lastUpdated',
},
prepare({title, productTitle, ageInDays, updatedAgeInDays, isOld}) {
const displayTitle = productTitle || title || 'Untitled Product'
const createdAgeText = ageInDays ? `${ageInDays}d old` : 'Unknown age'
const updatedAgeText = updatedAgeInDays
? `${updatedAgeInDays}d since update`
: 'Unknown'
const status = isOld ? '🔴 OLD' : '🟢 FRESH'
return {
title: displayTitle,
subtitle: `${status} - Created: ${createdAgeText} | Updated: ${updatedAgeText}`,
}
},
},
},
],
description: 'Automatically populated by the stale-products function',
readOnly: true,
}),import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
type: 'sanity.function.document',
name: 'stale-products',
memory: 1,
timeout: 30,
src: './functions/stale-products',
event: {
on: ['create', 'update'],
filter: "_type == 'page' && delta::changedAny(modules)",
projection: '{_id, _type, modules}',
},
}),
],
})import {documentEventHandler} from '@sanity/functions'
import {createClient} from '@sanity/client'
interface PagePayload {
_id: string
_type: string
modules?: Array<{
_type: string
items?: Array<{
_type: string
productWithVariant?: {
product?: {
_id: string
}
}
}>
}>
}
interface ProductWithDates {
_id: string
_createdAt: string
_updatedAt: string
title?: string
store?: {
title?: string
}
}
const STALE_PRODUCT_THRESHOLD_DAYS = 30
export const handler = documentEventHandler(async ({context, event}) => {
console.log('📄 Page Product Age Analysis Function called at', new Date().toISOString())
console.log('📝 Event:', event)
try {
const {_id, _type} = event.data as PagePayload
if (_type !== 'page') {
console.log('⏭️ Skipping non-page document:', _type)
return
}
const client = createClient({
...context.clientOptions,
useCdn: false,
apiVersion: '2025-06-01',
})
console.log('🔍 Analyzing product ages for page:', _id)
// Fetch the page with its modules and product data in one query
// We're intentionally showing a more complex query to demonstrate real world usage,
// you may have additional modules/fields with product references you'd want to analyze
const page = await client.fetch<{
_id: string
modules: Array<{
_type: string
items?: Array<{
_type: string
productWithVariant?: {
product?: {
_id: string
_createdAt: string
_updatedAt: string
title?: string
store?: {
title?: string
}
}
}
}>
}>
}>(
`*[_id == $id][0] {
_id,
modules[] {
_type,
(_type == 'grid') => {
items[] {
_type,
(_type == 'productReference') => {
productWithVariant {
product-> {
_id,
_createdAt,
_updatedAt,
title,
store {
title
}
}
}
}
}
}
}
}`,
{id: _id},
)
if (!page) {
console.log('❌ Page not found:', _id)
return
}
// Extract product data directly from the query result
const products: ProductWithDates[] = []
page.modules?.forEach((module) => {
if (module._type === 'grid' && module.items) {
module.items.forEach((item) => {
if (item._type === 'productReference' && item.productWithVariant?.product) {
const product = item.productWithVariant.product
if (product._id && product._createdAt && product._updatedAt) {
products.push({
_id: product._id,
_createdAt: product._createdAt,
_updatedAt: product._updatedAt,
title: product.title,
store: product.store,
})
}
}
})
}
})
// Remove duplicates based on product ID
const productMap = new Map<string, ProductWithDates>()
products.forEach((product) => {
productMap.set(product._id, product)
})
const uniqueProducts = Array.from(productMap.values())
console.log(
'📦 Found products:',
uniqueProducts.map((p) => p._id),
)
if (uniqueProducts.length === 0) {
console.log('ℹ️ No product references found in page modules')
// Clear existing product age analysis
await client
.patch(_id, {
set: {
productAgeAnalysis: [],
},
})
.commit()
return
}
console.log('📊 Retrieved product data for', uniqueProducts.length, 'products')
// Calculate age analysis
const now = new Date()
const productAgeAnalysis = uniqueProducts.map((product) => {
const createdAt = new Date(product._createdAt)
const updatedAt = new Date(product._updatedAt)
// Primary age calculation based on creation date (more important)
const createdAgeInDays = Math.floor(
(now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
)
// Secondary age calculation based on last update
const updatedAgeInDays = Math.floor(
(now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24),
)
// Flag as old if created more than 30 days ago
const isOld = createdAgeInDays > STALE_PRODUCT_THRESHOLD_DAYS
return {
_key: `product-age-${product._id}`,
product: {
_ref: product._id,
_type: 'reference',
},
lastUpdated: product._updatedAt,
createdAt: product._createdAt,
ageInDays: createdAgeInDays, // Primary age based on creation
updatedAgeInDays, // Secondary age based on last update
isOld,
}
})
// Sort by age (oldest first)
productAgeAnalysis.sort((a, b) => b.ageInDays - a.ageInDays)
console.log('📈 Product age analysis:', {
totalProducts: productAgeAnalysis.length,
oldProducts: productAgeAnalysis.filter((p) => p.isOld).length,
averageAge: Math.round(
productAgeAnalysis.reduce((sum, p) => sum + p.ageInDays, 0) / productAgeAnalysis.length,
),
})
// Update the page with the product age analysis
await client
.patch(_id, {
set: {
productAgeAnalysis,
},
})
.commit()
console.log('✅ Page product age analysis completed:', {
pageId: _id,
productsAnalyzed: productAgeAnalysis.length,
oldProductsFound: productAgeAnalysis.filter((p) => p.isOld).length,
})
} catch (error) {
console.error('❌ Error processing product:', error)
throw error
}
},
)The Problem: Products sitting unsold become a drain on your business—tying up capital, taking valuable warehouse space, and eventually becoming obsolete inventory you'll need to write off. Without visibility into slow-moving stock, you can't take action to move it before it loses all value. Manual tracking across multiple systems is time-consuming and error-prone.
The Solution: This function automatically identifies and flags stale products based on your custom criteria—whether it's time since last sale, inventory age, or seasonal relevance. Get alerts on products that need attention before they become dead stock, enabling you to run promotions, adjust pricing, or make strategic decisions to protect your margins.
Quick Start
View full instructions and source code at github.com/sanity-io/sanity/tree/feature/stale-products-functions/examples/functions/stale-products
Initialize blueprints if you haven't already:npx sanity blueprints init
Add the function to your project:npx sanity blueprints add function --example stale-products
Deploy to production:npx sanity blueprints deploy
How It Works
The function automatically monitors your product catalog and:
Key Benefits
Perfect For
For complete implementation details and advanced configuration options, visit the GitHub repository.
Developer at Sanity
Automatically track when content was first published with a timestamp that sets once and never overwrites, providing reliable publication history for analytics and editorial workflows.
Go to First Published Timestamp FunctionAI-powered automatic tagging for Sanity blog posts that analyzes content to generate 3 relevant tags, maintaining consistency by reusing existing tags from your content library.
Go to Automatically tag blog postsOn document publish, send a Slack notification so your team is informed
Go to Notify your team via Slack on publishCreate, preview, and send Klaviyo campaigns without ever leaving Sanity Studio"
Go to Klaviyo campaigns without leaving your Studio