Keep Authors Aware of Product Freshness
Surface product age to help editors choose the freshest inventory
By Kevin Green
schemaTypes/page.ts
// 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,
}),sanity.blueprint.ts
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
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}',
},
}),
],
})functions/stale-products/index.ts
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:
- Triggers on scheduled intervals or product updates
- Evaluates products against your staleness criteria
- Flags products that haven't sold within your threshold
- Updates product documents with staleness status
- Can trigger notifications or automated workflows
Key Benefits
- Prevent dead stock - Catch slow-moving inventory before it becomes obsolete
- Protect margins - Take action while products still have value
- Free up capital - Identify items to discount and convert to cash
- Optimize storage - Clear warehouse space for profitable inventory
- Automate monitoring - No more manual spreadsheet tracking
- Custom thresholds - Define staleness based on your business needs
Perfect For
- E-commerce operations managing large product catalogs
- Seasonal businesses needing to clear inventory before it's out of season
- Fashion and apparel where products quickly lose relevance
- Perishable goods requiring time-sensitive monitoring
For complete implementation details and advanced configuration options, visit the GitHub repository.
Contributor
Kevin Green
Developer at Sanity