Documentation
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Servers and scripts
  4. 04Ruby on Rails
  5. 05Ruby
  6. More platforms

    1. 06SvelteKit
    2. 07Nuxt and Nitro
    3. 08React Router and Remix
    4. 09NestJS
    5. 10Strapi
    6. 11Netlify
    7. 12Firebase
    8. 13Convex
    9. 14Trigger.dev
    10. 15Inngest
    11. 16Cloudflare Workers
    12. 17Supabase and pg_cron
  7. 18Schedules, grace and timeouts
  8. 19What it catches
  9. 20Alerts
  10. 21Stores
  11. 22Dashboard and API
  12. 23MCP server
  13. 24Agent skill
  14. 25AI triage
  15. 26API reference
  16. 27Limits and design notes

Nuxt and Nitro

Nuxt and Nitro

Nitro, the server under Nuxt, can run tasks on a cron schedule. On the Node presets (node-server, bun, deno-server, and nuxt dev) it schedules them itself, inside the server process, so the server can run the check too. Tasks are still experimental in Nitro 2, which Nuxt uses, so they need a flag.

The config

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    experimental: { tasks: true },
    scheduledTasks: {
      "0 * * * *": ["cleanup:sessions"],
    },
  },
});

For plain Nitro, the same two keys go in nitro.config.ts.

The client

Files in server/utils are imported automatically everywhere under server/.

// server/utils/cronwatch.ts
import { cronwatch } from "@cronwatch/sdk";
import { sqlite } from "@cronwatch/sdk/sqlite";
import { slack } from "@cronwatch/sdk/slack";

export const cw = cronwatch({
  store: sqlite({ path: "./data/cronwatch.db" }),
  alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
});

export const sessionCleanup = cw.job("cleanup-sessions", { schedule: "0 * * * *", grace: "5m", timeout: "10m" });

SQLite suits a long-running server with a disk that survives deploys. Keep the file outside .output, which each build replaces. On a host without a persistent disk, use postgres() from @cronwatch/sdk/postgres.

Nitro schedules with croner and passes no time zone, so a cron expression is read in the server’s local time. CronWatch reads a job without timezone the same way, so leave timezone unset and the two agree. If you set one, run the server with the same TZ.

A task

The task’s name comes from its path: server/tasks/cleanup/sessions.ts is cleanup:sessions. Wrap the body in run() and return what it returns.

// server/tasks/cleanup/sessions.ts
export default defineTask({
  meta: { name: "cleanup:sessions", description: "Delete expired sessions" },
  async run() {
    const removed = await sessionCleanup.run(async (job) => {
      const count = await deleteExpiredSessions();
      job.metric("removed", count);
      return count;
    });
    return { result: removed };
  },
});

A failed run rethrows, so Nitro logs Error while running scheduled task as it would without CronWatch; the failure has already been recorded and alerted.

Nitro runs at most one instance of a task at a time in each server process. A fire that arrives while the previous run is still going joins that run instead of starting another, so no new run is recorded. For a cron job that is reported as missed once the grace passes, which is what happened: the slot came and went with nothing new started. Give a slow task a timeout shorter than its interval, or a longer grace.

The check

A Nitro plugin starts the check when the server boots and closes the client when it shuts down.

// server/plugins/cronwatch.ts
export default defineNitroPlugin((nitroApp) => {
  cw.start("1m");
  nitroApp.hooks.hook("close", () => cw.close());
});

Several server instances sharing one Postgres store would each run every scheduled task and each check. Every run is recorded and conditions still open once, but queued alerts can be retried twice; see several instances.

The dashboard

A catch-all server route serves the dashboard and API. The [...path] route does not match /cronwatch itself, so an index.ts beside it re-exports the same handler.

// server/routes/cronwatch/[...path].ts
const routes = cw.routes({ basePath: "/cronwatch" });

export default defineEventHandler((event) => routes.handler(toWebRequest(event)));
// server/routes/cronwatch/index.ts
export { default } from "./[...path]";

defineEventHandler and toWebRequest come from h3 and are auto-imported in Nuxt. Set CRONWATCH_TOKEN and open /cronwatch?token=<it> once.

Serverless presets

On presets where Nitro does not run scheduled tasks itself, trigger the job from the platform’s scheduler instead. Put it in a server route wrapped with handler(), which is fetch-style:

// server/routes/api/cron/cleanup-sessions.get.ts
const run = sessionCleanup.handler(async (job) => {
  job.metric("removed", await deleteExpiredSessions());
});

export default defineEventHandler((event) => run(toWebRequest(event)));

Point the platform cron at it and at /cronwatch/api/check with CRON_SECRET as the bearer, drop the plugin, and use the Postgres store.