How to build customer-facing recurring schedules in Next.js
Covers
Next.js App Router, React, and TypeScript
The cron expression is the easy bit. Here is the complete path from a customer choosing a time to your application safely doing the work.
Start with the whole feature, not the cron expression
Most scheduling tutorials finish when a timer calls a function. That is enough for an internal script. It is not enough when a paying customer expects to create, inspect, pause, edit, and trust a recurring action inside your product.
A customer-facing schedule has three separate jobs:
- 1 Your product authenticates the customer, decides what they may schedule, and provides the UI in the right organization context.
- 2 The scheduling layer stores the recurrence, turns it into durable occurrences, delivers them, retries failures, and keeps history.
- 3 Your application receives the occurrence and performs the business action: generate the report, export the data, or send the message.
Keeping those jobs separate is useful even if you build every part yourself. The scheduler should not become a second authorization system, and it should not need access to the private code or data that produces the result.
Your product
Authenticated browser
Customer chooses recurrence
incld
Schedule lifecycle
State, occurrences, retries
Your product
Signed handler
Queues the business work
Your code remains the execution boundary. incld never runs your report, export, or domain action.
The implementation below uses incld for the scheduling layer. The same boundary applies to an in-house scheduler: keep browser requests and background delivery on separate trust paths.
Create one server-only integration boundary
Install the server adapter, provider, and schedule components. Keep the secret key and webhook secret in server-only environment variables.
npm install @incld/client @incld/react @incld/react-schedules
Next, define the actions a customer is allowed to schedule. An action is a stable identifier and payload contract—not an arbitrary URL supplied by the browser.
import "server-only"
import { createIncld, defineActions } from "@incld/client/next"
import { auth } from "@/lib/auth"
import { reports } from "@/lib/reports"
const actions = defineActions({
generate_report: {
displayName: "Generate report",
payloadSchema: {
type: "object",
properties: { accountId: { type: "string" } },
required: ["accountId"],
},
async run({ payload, event }) {
await reports.generate(payload.accountId, {
idempotencyKey: event.idempotencyKey,
})
},
},
})
export const incld = createIncld({
apiKey: process.env.INCLD_SECRET_KEY!,
webhookSecret: process.env.INCLD_WEBHOOK_SECRET!,
baseUrl: process.env.INCLD_API_URL,
actions,
async resolveContext() {
const session = await auth()
if (!session?.user.id || !session.organizationId) return null
return {
user: { id: session.user.id },
organization: { id: session.organizationId },
roles: session.user.roles,
permissions: session.user.permissions,
}
},
async authorize({ context, operation }) {
return context.permissions?.includes(`incld:${operation}`) === true
},
})
Two details carry most of the security weight.
resolveContext
reads identity from your authenticated server session, and
authorize
applies your permission model to each operation. Neither trusts an organization or user ID posted by the browser.
Run
await incld.syncActions()
during deployment after the new code is available. Synchronization is an idempotent upsert, so action names and payload schemas stay aligned with the handler you have deployed.
Mount the browser proxy and webhook separately
These routes may sit next to each other in the filesystem, but they do opposite jobs. The proxy starts with a signed-in customer and calls incld. The webhook starts with incld and calls your action handler.
import { incld } from "@/lib/incld"
export const dynamic = "force-dynamic"
export const { GET, POST, PATCH, DELETE } = incld.routes
import { incld } from "@/lib/incld"
export const dynamic = "force-dynamic"
export const POST = incld.webhook
The proxy only forwards known operations. It strips protected identity fields from browser input, injects the organization and user returned by resolveContext, and passes your project key upstream. The webhook verifies the signature against the raw request body before it looks up an action.
Keep the two paths separate in monitoring too. A proxy 403 usually means authentication or authorization failed. A webhook 5xx means incld delivered an occurrence but your handler could not accept it.
Add schedule controls where the job already makes sense
A schedule button works best beside the report, export, or notification it controls. Customers should not have to translate your product into a generic “jobs” screen before they can automate it.
"use client"
import { IncldProvider, type IncldProviderProps } from "@incld/react"
export function Providers({ children }: { children: IncldProviderProps["children"] }) {
return (
<IncldProvider
baseUrl="/api/incld"
appearance={{ colorScheme: "system", accentColor: "indigo" }}
onError={(error) => console.error(error.code, error.requestId)}
>
{children}
</IncldProvider>
)
}
"use client"
import { ScheduleList, ScheduleTrigger } from "@incld/react-schedules"
export function ReportAutomation() {
return (
<section>
<ScheduleTrigger
action="generate_report"
defaultPayload={{ accountId: "acct_42" }}
>
Schedule report
</ScheduleTrigger>
<ScheduleList filters={{ action: "generate_report" }} />
</section>
)
}
The provider points at /api/incld; the client appends /v1.
The list is therefore scoped by the same active server session used when the schedule was created. A browser cannot switch tenants by changing the payload.
Model the customer’s local-time promise
Store recurrence as a rule plus an IANA timezone. If the customer says “every Monday and Friday at 9am in Melbourne,” that local promise should survive the offset changing from UTC+10 to UTC+11.
import { Incld } from "@incld/client"
const tenantIncld = new Incld({
apiKey: process.env.INCLD_SECRET_KEY!,
scope: {
organizationId: session.organizationId,
userId: session.user.id,
},
})
const schedule = await tenantIncld.schedules.create(
{
action: "generate_report",
payload: { accountId: "acct_42" },
recurrence: {
frequency: "weekly",
interval: 1,
weekdays: ["monday", "friday"],
localTime: "09:00",
timezone: "Australia/Melbourne",
},
timezone: "Australia/Melbourne",
overlapPolicy: "skip",
misfirePolicy: "run_once",
},
{ idempotencyKey: "report-schedule:acct_42" },
)
Resolve
session
and authorize the action before constructing that client. In a customer request path, use a tenant-scoped server client or the browser proxy. Reserve an unscoped administrator client for deployment and operator work. The embedded composer uses the proxy path by default.
Before saving, ask the preview endpoint for the next few occurrences and show both local and UTC values in development. A preview catches a surprising timezone choice much earlier than a support ticket.
const preview = await tenantIncld.schedules.preview({
recurrence,
count: 10,
from: new Date().toISOString(),
})
// preview.summary
// preview.occurrences[].local
// preview.occurrences[].utc
// preview.occurrences[].timezoneAbbreviation
Treat signed delivery as at least once
incld signs each delivery and retries failures. That improves reliability, but it also means your handler must tolerate seeing the same logical event more than once. A signature proves who sent the request; it does not make a side effect unique.
The framework adapter exposes the event ID as event.idempotencyKey.
Persist that key in the same durable system that accepts the work. If the action sends an email directly and only records the key afterward, a crash between those two statements can still send twice.
async run({ payload, event }) {
await jobs.enqueueUnique({
key: event.idempotencyKey,
type: "generate_report",
organizationId: event.context?.organization_id,
payload,
})
}
Each run carries a payload snapshot and schedule revision. Editing tomorrow’s recurrence should not quietly change a run already created for today.
Design the failure path before the happy path ships
These failure cases are predictable. Decide how each one appears to customers and operators before the feature ships.
| Failure | What the customer should see | What the system should do |
|---|---|---|
| Webhook times out | Run is retrying | Retry delivery; keep one idempotency key |
| Handler rejects payload | Run failed with a useful reason | Return a non-2xx response; do not silently discard |
| Schedule is edited | Future time changes; prior history remains | Increment revision and snapshot created work |
| Schedule is paused | Paused state and no future delivery | Preserve history; recompute the next run on resume |
| Monthly day is absent | A documented, previewable date | Use an explicit month-end rule rather than guessing |
Test the rendered state, not just API responses. A retry badge nobody can find is still a support problem. The operator also needs the response status, attempt count, and enough redacted error detail to distinguish a cold start from a permanently invalid payload.
A compact production checklist
- 1 Action identifiers are declared in code and synchronized during deployment.
- 2 The browser proxy resolves organization and user identity from the server session.
- 3 Every create operation has a stable idempotency key tied to your domain intent.
- 4 Recurring rules store an IANA timezone and can be previewed across a DST boundary.
- 5 The webhook secret is server-only and signature verification uses the untouched body.
- 6 The handler deduplicates before external side effects and returns a failure when work was not accepted.
- 7 Customers can inspect, pause, resume, edit, and delete schedules from the relevant product surface.
- 8 Operators can see run state, attempts, response status, schedule revision, and customer scope.
Use these checks as a release gate. Passing them separates a complete customer-facing scheduling feature from a cron endpoint with a form in front of it.
Ship customer-facing schedules with the boundary intact.
incld hosts recurrence state, signed delivery, retries, history, and React UI. Your application keeps authorization, domain data, and the work itself.
Related engineering notes
IANA timezones, DST, misfires, and overlap policies for SaaS schedules
“Every day at 9” is a product promise, not a UTC interval. Model the promise first, then decide what should happen when time or execution gets awkward.
Multi-tenant cron jobs: the authorization boundary most examples omit
A tenant ID in the request body is not an authorization boundary. Follow the identity chain from the signed-in browser to delayed background work.