Engineering notes
Build guide 14 min read

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. 1 Your product authenticates the customer, decides what they may schedule, and provides the UI in the right organization context.
  2. 2 The scheduling layer stores the recurrence, turns it into durable occurrences, delivers them, retries failures, and keeps history.
  3. 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.

The complete schedule path

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.

Terminal sh
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.

src/lib/incld.ts ts
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.

app/api/incld/v1/[...path]/route.ts ts
import { incld } from "@/lib/incld"

export const dynamic = "force-dynamic"
export const { GET, POST, PATCH, DELETE } = incld.routes
app/api/incld/webhook/route.ts ts
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.

src/app/providers.tsx tsx
"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>
 )
}
src/app/report-automation.tsx tsx
"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.

Create a weekly report schedule from a server request ts
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.

Preview the next ten occurrences ts
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.

Queue first, execute later ts
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 outRun is retryingRetry delivery; keep one idempotency key
Handler rejects payloadRun failed with a useful reasonReturn a non-2xx response; do not silently discard
Schedule is editedFuture time changes; prior history remainsIncrement revision and snapshot created work
Schedule is pausedPaused state and no future deliveryPreserve history; recompute the next run on resume
Monthly day is absentA documented, previewable dateUse 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. 1 Action identifiers are declared in code and synchronized during deployment.
  2. 2 The browser proxy resolves organization and user identity from the server session.
  3. 3 Every create operation has a stable idempotency key tied to your domain intent.
  4. 4 Recurring rules store an IANA timezone and can be previewed across a DST boundary.
  5. 5 The webhook secret is server-only and signature verification uses the untouched body.
  6. 6 The handler deduplicates before external side effects and returns a failure when work was not accepted.
  7. 7 Customers can inspect, pause, resume, edit, and delete schedules from the relevant product surface.
  8. 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.