Apps that do things on their own
The best apps aren't just reactive — they do things proactively. Every night the database should clean up expired sessions. Every morning a digest email should go out to users. Every hour prices should sync from an external API. Without scheduled tasks, you end up manually triggering these things or forgetting about them entirely.
Spinini's Scheduled Tasks panel builds cron scheduling directly into your project. No separate cron server, no external service, no configuration files.
Setting up a scheduled task
Click the Clock icon in the sidebar to open the Scheduled Tasks panel.
Click New task and fill in:
- Name — a human-readable label ("Nightly cleanup", "Hourly price sync")
- Schedule — a cron expression (see below)
- Trigger — the URL in your app to call, or a terminal command to run
Click Save. The task is active immediately.
Understanding cron expressions
Cron expressions are five fields: minute, hour, day-of-month, month, day-of-week.
┌─── minute (0-59)
│ ┌─── hour (0-23)
│ │ ┌─── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *Common examples:
| Expression | Meaning |
| `0 9 * * *` | Every day at 9:00 AM |
| `0 * * * *` | Every hour on the hour |
| `*/15 * * * *` | Every 15 minutes |
| `0 9 * * 1` | Every Monday at 9 AM |
| `0 0 1 * *` | First day of every month at midnight |
The panel has a helper UI that builds the expression for you if you prefer not to write cron syntax manually.
Common use cases
Database cleanup
Remove expired sessions, old notifications, stale temporary records:
// routes/tasks/cleanup.js
export async function GET() {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 7 days ago
const { count } = await prisma.session.deleteMany({
where: { expiresAt: { lt: cutoff } }
})
console.log(`Deleted ${count} expired sessions`)
return Response.json({ deleted: count })
}Set the task to trigger GET /tasks/cleanup every night at 2 AM: 0 2 * * *
Email digest
Send a weekly summary email to all active users:
// routes/tasks/weekly-digest.js
export async function POST() {
const users = await prisma.user.findMany({
where: { emailNotifications: true },
include: { recentActivity: { take: 5 } }
})
for (const user of users) {
await sendEmail({
to: user.email,
subject: 'Your weekly summary',
html: renderDigestTemplate(user)
})
}
return Response.json({ sent: users.length })
}Schedule: 0 9 * * 1 (Monday 9 AM)
Price sync from external API
// routes/tasks/sync-prices.js
export async function GET() {
const prices = await fetch('https://api.yourprovider.com/prices')
.then(r => r.json())
for (const [id, price] of Object.entries(prices)) {
await prisma.product.update({
where: { externalId: id },
data: { price: price.usd }
})
}
return Response.json({ updated: Object.keys(prices).length })
}Schedule: 0 * * * * (every hour)
Report generation
# scripts/generate_report.py
import json
from datetime import datetime, timedelta
from db import query
yesterday = datetime.now() - timedelta(days=1)
metrics = {
'new_signups': query('SELECT COUNT(*) FROM users WHERE created_at > %s', [yesterday]),
'active_users': query('SELECT COUNT(DISTINCT user_id) FROM events WHERE ts > %s', [yesterday]),
'revenue': query('SELECT SUM(amount) FROM payments WHERE created_at > %s', [yesterday])
}
print(json.dumps(metrics))Set the task as a command: python scripts/generate_report.py >> /var/log/reports.log 2>&1
Schedule: 0 6 * * * (6 AM daily)
Securing your task endpoints
If your task endpoints modify data, protect them from being called by anyone on the internet:
export async function GET(request) {
const token = request.headers.get('x-task-token')
if (token !== process.env.TASK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// ... do the work
}Set TASK_SECRET in your project's Secrets panel. Configure the same value in the Scheduled Tasks panel's "Headers" field for that task.
Monitoring and logs
The Scheduled Tasks panel shows each task's:
- Last run time — when it was last triggered
- Last result — the HTTP status code or exit code
- Output — the last 500 lines of stdout/stderr from the task
If a task fails, the output shows the error so you can debug it directly without guessing.