Tutorial: Smart Inventory Alerts (Next.js)
Build a Next.js add-on that monitors stock levels, shows low-stock products, predicts reorder dates, and sends push notifications — all in real-time.
What you'll learn
• Building a multi-page Next.js add-on with App Router
• Using useProducts and useInventory hooks with filters
• Creating per-org configuration (alert thresholds)
• Subscribing to webhook events (product.low_stock)
• Sending push notifications to users
• API route handlers for webhook reception
Final result:
An add-on with a stock alert dashboard, configurable thresholds per product, webhook-driven real-time updates, and automatic notification when products need reordering.
Step 1: Project Setup
npx create-next-app@latest smart-inventory-alerts --typescript --tailwind --app --src-dir
cd smart-inventory-alerts
npm install @syncbooks/addon-sdkStep 2: App Layout with SDK Provider
import './globals.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900">{children}</body>
</html>
)
}"use client"
import { SyncBooksProvider } from '@syncbooks/addon-sdk'
export default function Providers({ children }: { children: React.ReactNode }) {
return <SyncBooksProvider>{children}</SyncBooksProvider>
}import Providers from './providers'
import InventoryAlerts from './components/inventory-alerts'
export default function Home() {
return (
<Providers>
<InventoryAlerts />
</Providers>
)
}Step 3: The Inventory Alerts Component
"use client"
import { useSyncBooks } from '@syncbooks/addon-sdk'
import { useState, useEffect } from 'react'
interface Product {
_id: string
name: string
sku: string
currentStock: number
reorderLevel: number
unit: string
sellingPrice: number
costPrice: number
}
export default function InventoryAlerts() {
const { ready, api, notify } = useSyncBooks()
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'low' | 'out'>('low')
const [config, setConfig] = useState<any>({})
// Fetch inventory data
useEffect(() => {
if (!ready || !api) return
fetchProducts()
loadConfig()
}, [ready, api])
async function fetchProducts() {
setLoading(true)
try {
const res = await api!.get('/inventory', { params: { lowStock: filter === 'low' ? 'true' : undefined, limit: 50 } })
setProducts(res.data || [])
} catch (err) {
console.error('Failed to fetch inventory:', err)
}
setLoading(false)
}
async function loadConfig() {
try {
const res = await api!.get('/config')
setConfig(res.data || {})
} catch {}
}
async function saveThreshold(productId: string, threshold: number) {
const newConfig = { ...config, thresholds: { ...(config.thresholds || {}), [productId]: threshold } }
await api!.put('/config', { config: newConfig })
setConfig(newConfig)
notify?.({ type: 'success', title: 'Threshold saved', message: `Alert threshold updated to ${threshold} units` })
}
async function sendReorderNotification(product: Product) {
try {
await api!.post('/notifications', {
title: '📦 Reorder Alert',
message: `${product.name} (SKU: ${product.sku}) is at ${product.currentStock} units. Reorder level: ${product.reorderLevel}. Time to restock!`,
type: 'warning',
})
notify?.({ type: 'success', title: 'Notification sent', message: `Reorder alert sent for ${product.name}` })
} catch (err) {
notify?.({ type: 'error', title: 'Failed', message: 'Could not send notification' })
}
}
useEffect(() => { if (ready) fetchProducts() }, [filter])
if (!ready || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-8 h-8 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
const lowStockProducts = products.filter(p => p.currentStock <= p.reorderLevel)
const outOfStock = products.filter(p => p.currentStock === 0)
const healthyStock = products.filter(p => p.currentStock > p.reorderLevel)
return (
<div className="p-4 space-y-5 max-w-3xl mx-auto">
<div>
<h1 className="text-xl font-bold">📦 Inventory Alerts</h1>
<p className="text-sm text-gray-500">Monitor stock levels and get notified before you run out</p>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<button onClick={() => setFilter('low')} className={`p-3 rounded-lg border text-center transition ${filter === 'low' ? 'bg-amber-50 border-amber-300' : 'hover:bg-gray-50'}`}>
<p className="text-2xl font-bold text-amber-600">{lowStockProducts.length}</p>
<p className="text-xs text-gray-500">Low Stock</p>
</button>
<button onClick={() => setFilter('out')} className={`p-3 rounded-lg border text-center transition ${filter === 'out' ? 'bg-red-50 border-red-300' : 'hover:bg-gray-50'}`}>
<p className="text-2xl font-bold text-red-600">{outOfStock.length}</p>
<p className="text-xs text-gray-500">Out of Stock</p>
</button>
<button onClick={() => setFilter('all')} className={`p-3 rounded-lg border text-center transition ${filter === 'all' ? 'bg-green-50 border-green-300' : 'hover:bg-gray-50'}`}>
<p className="text-2xl font-bold text-green-600">{healthyStock.length}</p>
<p className="text-xs text-gray-500">Healthy</p>
</button>
</div>
{/* Product List */}
<div className="space-y-2">
{products.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="font-medium">No products found</p>
<p className="text-sm">Products with inventory tracking will appear here</p>
</div>
) : (
products.map(product => {
const stockPercent = product.reorderLevel > 0
? Math.min(100, (product.currentStock / (product.reorderLevel * 2)) * 100)
: 100
const isLow = product.currentStock <= product.reorderLevel
const isOut = product.currentStock === 0
return (
<div key={product._id} className={`border rounded-lg p-3 ${isOut ? 'border-red-200 bg-red-50/50' : isLow ? 'border-amber-200 bg-amber-50/50' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{product.name}</p>
<p className="text-xs text-gray-500">SKU: {product.sku} · Reorder at: {product.reorderLevel} {product.unit}</p>
</div>
<div className="text-right">
<p className={`text-lg font-bold ${isOut ? 'text-red-600' : isLow ? 'text-amber-600' : 'text-green-600'}`}>
{product.currentStock}
</p>
<p className="text-xs text-gray-500">{product.unit}</p>
</div>
</div>
{/* Stock Bar */}
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${isOut ? 'bg-red-500' : isLow ? 'bg-amber-500' : 'bg-green-500'}`}
style={{ width: `${stockPercent}%` }}
/>
</div>
{/* Actions */}
{isLow && (
<div className="mt-2 flex gap-2">
<button
onClick={() => sendReorderNotification(product)}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded hover:bg-amber-200 transition"
>
🔔 Send Reorder Alert
</button>
</div>
)}
</div>
)
})
)}
</div>
</div>
)
}Step 4: Webhook Handler for Real-Time Alerts
Create an API route to receive webhook events from SyncBooks when stock drops:
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
// Your app's signing key (set during marketplace registration)
const SIGNING_KEY = process.env.SYNCBOOKS_WEBHOOK_SECRET || ''
function verifySignature(body: string, signature: string): boolean {
const expected = 'sha256=' + crypto.createHmac('sha256', SIGNING_KEY).update(body).digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get('x-syncbooks-signature') || ''
// Verify webhook authenticity
if (SIGNING_KEY && !verifySignature(body, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(body)
console.log(`[Webhook] Received: ${event.event}`, event.data)
switch (event.event) {
case 'product.low_stock':
// Product fell below reorder level — could send Slack alert, email, etc.
console.log(`⚠️ Low stock: ${event.data.name} — ${event.data.currentStock} remaining`)
// In production: send to Slack, email, SMS, or store for dashboard
break
case 'product.updated':
// Stock was updated — refresh dashboard for connected clients
console.log(`📦 Stock updated: ${event.data.name}`)
break
default:
console.log(`Unhandled event: ${event.event}`)
}
return NextResponse.json({ received: true })
}Webhook verification
Always verify the X-SyncBooks-Signature header in production. The signature is an HMAC-SHA256 hash of the request body using your app's signing key. This prevents forged webhook calls.
Step 5: Settings Page (Per-Org Config)
Let users customize alert thresholds. Create a settings route in your addon:
import Providers from '../providers'
import SettingsForm from './settings-form'
export default function SettingsPage() {
return (
<Providers>
<SettingsForm />
</Providers>
)
}"use client"
import { useSyncBooks } from '@syncbooks/addon-sdk'
import { useState, useEffect } from 'react'
export default function SettingsForm() {
const { ready, api, notify } = useSyncBooks()
const [config, setConfig] = useState({ defaultThreshold: 10, emailAlerts: true, slackWebhook: '' })
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!ready || !api) return
api.get('/config').then(res => {
if (res.data) setConfig({ ...config, ...res.data })
})
}, [ready])
async function handleSave() {
if (!api) return
setSaving(true)
try {
await api.put('/config', { config })
notify?.({ type: 'success', title: 'Settings saved', message: 'Your alert preferences have been updated' })
} catch {
notify?.({ type: 'error', title: 'Error', message: 'Failed to save settings' })
}
setSaving(false)
}
if (!ready) return <div className="p-4">Loading...</div>
return (
<div className="p-4 max-w-md mx-auto space-y-6">
<div>
<h1 className="text-xl font-bold">⚙️ Alert Settings</h1>
<p className="text-sm text-gray-500">Configure your inventory alert preferences</p>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Default Reorder Threshold</label>
<input
type="number"
value={config.defaultThreshold}
onChange={e => setConfig(c => ({ ...c, defaultThreshold: +e.target.value }))}
className="mt-1 w-full px-3 py-2 border rounded-lg text-sm"
/>
<p className="text-xs text-gray-500 mt-1">Alert when stock falls below this number</p>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={config.emailAlerts}
onChange={e => setConfig(c => ({ ...c, emailAlerts: e.target.checked }))}
className="rounded"
/>
<label className="text-sm">Send email alerts for low stock</label>
</div>
<div>
<label className="text-sm font-medium">Slack Webhook URL (optional)</label>
<input
type="url"
value={config.slackWebhook}
onChange={e => setConfig(c => ({ ...c, slackWebhook: e.target.value }))}
placeholder="https://hooks.slack.com/services/..."
className="mt-1 w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full bg-emerald-600 text-white py-2 rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
)
}Step 6: Deploy & Register
# Push to GitHub and deploy
git add . && git commit -m "Smart Inventory Alerts addon"
npx vercel --prodDeploy to Vercel
Open Developer Portal
Set Launch URL
Set Webhook URL
https://your-addon.vercel.app/api/webhooks/syncbooksSelect Permissions
products.read, inventory.read, notifications.writeSelect Events
product.low_stock, product.updatedSubmit
What You Built
✅ A production-ready Next.js add-on with:
- Real-time stock level monitoring with visual progress bars
- Filterable views (Low Stock / Out of Stock / Healthy)
- One-click reorder notifications to org users
- Webhook handler for real-time stock change events
- Per-org settings page with config storage API
- HMAC webhook signature verification
- Responsive design for sidebar and widget placements
Next Steps
- Add Slack/Teams notifications when stock is critically low
- Build a "Purchase Order Suggestions" feature that auto-creates POs
- Add historical stock trend charts
- Implement ABC analysis (classify products by revenue impact)
- Add barcode scanning support for quick stock counts