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

React Router and Remix

React Router and Remix

A resource route is a route module with a loader or action and no component. Its loader and action get the fetch Request and may return a Response, which is all handler() and routes() need. Jobs are endpoints called by a scheduler outside the app, and the dashboard is one splat route.

The client

The .server suffix keeps the module out of the browser bundle.

// app/lib/cronwatch.server.ts
import { cronwatch } from "@cronwatch/sdk";
import { postgres } from "@cronwatch/sdk/postgres";
import { slack } from "@cronwatch/sdk/slack";

export const cw = cronwatch({
  store: postgres({ connectionString: process.env.DATABASE_URL }),
  alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
  defaults: { timezone: "UTC" },
});

export const digest = cw.job("daily-digest", { schedule: "0 6 * * *", grace: "10m", timeout: "5m" });

Postgres for serverless hosts; SQLite (@cronwatch/sdk/sqlite) is fine for a long-running server with a persistent disk. timezone: "UTC" matches Vercel’s crons; for crontab or another scheduler, use its zone.

Routes

In React Router framework mode, add both routes to app/routes.ts:

// app/routes.ts
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [
  // ...your other routes
  route("api/cron/daily-digest", "routes/api.cron.daily-digest.ts"),
  route("cronwatch/*", "routes/cronwatch.ts"),
] satisfies RouteConfig;

A splat matches the path with nothing after it too, so cronwatch/* serves /cronwatch and everything below it.

A job endpoint

// app/routes/api.cron.daily-digest.ts
import { digest } from "~/lib/cronwatch.server";
import type { Route } from "./+types/api.cron.daily-digest";

const run = digest.handler(async (job) => {
  const sent = await sendDigest();
  job.metric("emails", sent);
});

export const loader = ({ request }: Route.LoaderArgs) => run(request);

The scheduler sends GET /api/cron/daily-digest with Authorization: Bearer <CRON_SECRET>. The handler checks it, records the run, and answers 200 or 500. Use action instead of loader if your scheduler sends POST.

The dashboard

// app/routes/cronwatch.ts
import { cw } from "~/lib/cronwatch.server";
import type { Route } from "./+types/cronwatch";

const routes = cw.routes({ basePath: "/cronwatch" });

export const loader = ({ request }: Route.LoaderArgs) => routes.handler(request);
export const action = ({ request }: Route.ActionArgs) => routes.handler(request);

The loader serves the pages and the JSON API; the action takes the dashboard’s form posts and API writes. Set CRONWATCH_TOKEN and open /cronwatch?token=<it> once.

Remix 2

The same modules work in Remix 2 with its file routes and argument types. The splat file cronwatch.$.ts matches /cronwatch too.

// app/routes/cronwatch.$.ts
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { cw } from "~/lib/cronwatch.server";

const routes = cw.routes({ basePath: "/cronwatch" });

export const loader = ({ request }: LoaderFunctionArgs) => routes.handler(request);
export const action = ({ request }: ActionFunctionArgs) => routes.handler(request);

A job endpoint is app/routes/api.cron.daily-digest.ts with a loader like the one above.

Where the check runs

On a serverless host, add a cron for /cronwatch/api/check, sent with CRON_SECRET as the bearer; the check endpoint accepts the cron secret as well as the dashboard token.

On a long-running server (react-router-serve, or your own Express server), call cw.start() once when the server starts. In a custom server, that is the file that calls listen(); import the client there. With react-router-serve there is no such file, so a cron hitting the check endpoint is the simpler route there too.

Limits that matter

When a platform kills a function at its time limit, the run cannot record its end. It is marked stuck by the first check after its timeout, so set timeout a little above the function’s maximum duration.