Introduction to Persistent Cron Jobs in Strapi
Strapi provides built-in cron job support for scheduled tasks.
Jobs are registered in memory and lost on restarts. For some production-grade task scheduling applications, especially those that require visibility, persistence, and manual control, we need a more robust approach.
In this 2-part tutorial series, we will focus on building a job system that does the following:
- Logs executions
- Allows manual triggering
- Persists job definitions in the database
- Integrates with a Next.js UI frontend.
Here is a demo of the final application we'll be building:
Tutorial Roadmap: What You’ll Learn
This tutorial is divided into two sections.
- Part 1: Setting up Strapi and Extending Strapi Cron Jobs
- Part 2: Building the Next.js Dashboard for Managing Cron Jobs
Cron Jobs in Strapi Explained
A cron job in Strapi is a scheduled task that allows you to execute arbitrary functions at specific dates and times, with optional recurrence rules.
Cron jobs can be used for tasks such as sending emails, for example, once a month cron job for emails, creating backups, or any other automated process you want to run on a schedule.
You can learn more from the Strapi documentation page.
Real-World Use Cases of Strapi Cron Jobs
Here are some real-world examples of when a persistent, managed cron job system is essential:
- Order Processing in E-commerce: Send order confirmation emails and schedule shipment updates. If anything fails, the admin can re-trigger cron jobs.
- Marketing Campaigns: Manage emails such as once a month cron job for emails or SMS campaign schedules, pause/resume cron jobs, and analyze failures.
- Automated Cleanup: Schedule database cleanups or report generations with tracking and retries if jobs fail.
Prerequisites
- Familiarity with Strapi 5
- Familiarity with Strapi 5 Cron jobs
- Node.js (LTS version 20++) and a package manager like npm or yarn
Setting up Strapi and Strap Content Types
Step 1: Create Strapi 5 Project
Create a new Strapi project by running the command below:
1npx create-strapi@latest strapi-cron-job-back --quickstart
In the command above, the name of the Strapi project is strapi-cron-job-back
. Feel free to give yours any name you want.
Once the server starts, open the admin panel and set up your first user.
Step 2: Define Content Types
Before we can build a persistent and manageable background cron job system, we need to define content types that will store cron job configurations and execution logs in the database.
These content types form the foundation of our system, ensure jobs persist across server restarts, and provide the data structure we need to track, manage, and interact with jobs through both APIs and the frontend.
Let’s start by defining content types.
We’ll create two content types:
- cron-job: Stores job data
- job-log: Stores execution results for each job
To create the necessary content types, we’ll use the Strapi CLI. This will scaffold the cron-job
and job-log
content types for us.
Step 3: Generate the cron-job
Content Type
Open a terminal in the root directory of the Strapi project and run:
npm run strapi generate
This will launch an interactive prompt where we can define fields for the cron-job
content type. We'll skip adding fields at this point, as we’ll paste the full schema in a later step.
Use the image as a guide for the prompts:
Step 4: Generate the job-log
Content Type
Next, run:
npm run strapi generate
Same as before, we'll skip adding fields in the interactive prompt. We’ll replace the generated schema files with the fully configured JSON definitions.
Here's the guide for the prompt:
Updating Strapi Cron Job Schemas
Update the cron-job
Schema
After generating the content type, open the schema file for the cron-job
collection type:
src/api/cron-job/content-types/cron-job/schema.json
Replace its contents with the following:
1{
2 "kind": "collectionType",
3 "collectionName": "cron_jobs",
4 "info": {
5 "singularName": "cron-job",
6 "pluralName": "cron-jobs",
7 "displayName": "Cron Job",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "name": {
15 "type": "string",
16 "required": true,
17 "unique": true
18 },
19 "description": {
20 "type": "text"
21 },
22 "schedule": {
23 "type": "string",
24 "required": true
25 },
26 "enabled": {
27 "type": "boolean",
28 "default": true
29 },
30 "lastRunAt": {
31 "type": "datetime"
32 },
33 "nextRunAt": {
34 "type": "datetime"
35 },
36 "runCount": {
37 "default": 0,
38 "type": "integer"
39 },
40 "job_logs": {
41 "type": "relation",
42 "relation": "oneToMany",
43 "target": "api::job-log.job-log",
44 "mappedBy": "cron_job"
45 },
46 "IsDeleted": {
47 "type": "boolean",
48 "default": false
49 },
50 "displayName": {
51 "type": "string",
52 "required": true
53 },
54 "timeZone": {
55 "type": "string"
56 },
57 "tags": {
58 "type": "json"
59 },
60 "options": {
61 "type": "json",
62 "required": true
63 }
64 }
65}
The cron-job
schema defines the configuration needed for jobs, which makes it the central point for creating, updating, deleting, or triggering cron jobs.
Here are some of the fields and schema changes we created above:
name
: The unique identifier for the job (e.g., send-order-confirmation).displayName
: A human-readable name used in the admin or frontend.description
: Provides a brief description of what the job does.- schedule: The actual cron syntax (e.g., 0 9 * * 1-5) that determines when the job will run.
enabled
: A boolean to enable or disable the job at runtime.lastRunAt
: Records the timestamp of the last successful execution.nextRunAt
: Shows when the job is scheduled to run next.runCount
: A counter for how many times this job has run.options
: A JSON object for passing arbitrary configuration or parameters to the job.tags
: An optional JSON array for categorization or easy filtering (e.g., "email", "campaign").IsDeleted
: Enables soft deletion, making jobs disappear from active listings while preserving history.job_logs
: Defines a one-to-many relation with thejob-log
content type, making it easy to trace execution history.
Update the job-log
Schema
Next, open src/api/job-log/content-types/job-log/schema.json
and replace its contents with the following:
1{
2 "kind": "collectionType",
3 "collectionName": "job_logs",
4 "info": {
5 "singularName": "job-log",
6 "pluralName": "job-logs",
7 "displayName": "Job Log",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "startedAt": {
15 "type": "datetime",
16 "required": true
17 },
18 "endedAt": {
19 "type": "datetime",
20 "required": false
21 },
22 "durationMs": {
23 "type": "biginteger"
24 },
25 "message": {
26 "type": "text"
27 },
28 "manualTrigger": {
29 "type": "boolean",
30 "default": false
31 },
32 "cron_job": {
33 "type": "relation",
34 "relation": "manyToOne",
35 "target": "api::cron-job.cron-job",
36 "inversedBy": "job_logs"
37 },
38 "jobStatus": {
39 "type": "enumeration",
40 "enum": [
41 "success",
42 "error",
43 "skipped",
44 "running",
45 "canceled"
46 ],
47 "default": "running",
48 "required": true
49 },
50 "executionId": {
51 "type": "uid",
52 "required": true
53 },
54 "errorStack": {
55 "type": "text"
56 }
57 }
58}
The job-log
schema allows administrators to track every execution attempt and makes it easy to review status, review error details, and investigate failures.
Here are some of the fields and schema changes we created above:
startedAt
: The date and time when the job started.endedAt
: The date and time when the job finished.durationMs
: The total time it took for the job to run, in milliseconds.message
: A human-readable message about the result, status, or error.manualTrigger
: A boolean that flags if this run was triggered manually (e.g., from the frontend).jobStatus
: An enumeration for status (success, error, skipped, running, canceled), making it easy to filter and review jobs.executionId
: A unique identifier for this specific run, making it traceable across logs.errorStack
: Captures the error traceback if an error occurs.cron_job
: Defines the link between this log entry and its parent job.
Creating Cron Job and Log Services in Strapi
With cron jobs and logging schema in place, the next piece of the architecture is creating services that will operate on these database collections.
These services contain common operations such as creating jobs, finding jobs by name, recording run instances, and updating status information.
Create Strapi Cron Job Service
The cron job service is where we define how jobs are saved and retrieved from the database.
This service makes sure that every job has a corresponding record in the database, and it handles both looking up existing jobs and creating new ones as needed.
To set this up, open the file src/api/cron-job/services/cron-job.ts
and update the code:
1import { factories } from "@strapi/strapi";
2import { camelToTitle, CRON_JOB_UID } from "../../../utils/helpers";
3
4const jobDoc = () => strapi.documents(CRON_JOB_UID);
5
6export default factories.createCoreService(
7 "api::cron-job.cron-job",
8 ({ strapi }) => ({
9 async findJob(name: string) {
10 let response = await jobDoc().findFirst({
11 filters: { name: name },
12 });
13
14 return response;
15 },
16
17 async createJob(job: any) {
18 const opts = job.options;
19 const meta = opts.meta;
20
21 const result = await jobDoc().create({
22 data: {
23 name: job.name,
24 displayName: meta.displayName ?? camelToTitle(job.name),
25 schedule: opts.rule,
26 timeZone: opts.tz,
27 description: meta.description,
28 enabled: meta.enabled ?? true,
29 tags: JSON.stringify(meta.tags ?? []),
30 options: JSON.stringify(opts),
31 },
32 });
33
34 return result;
35 },
36 })
37);
The findJob
function searches the database for a job that matches a given name. We will use this function to ensure that the system doesn’t accidentally create duplicates or lose track of jobs that were previously registered.
The createJob
function handles adding a new job to the database. It reads the job’s options
and meta
data, like the job’s name, schedule rule, time zone, display name, description, tags, and whether the job is enabled.
If certain values aren’t provided, for example, if no display name is specified, it applies sensible defaults by converting the job’s name into a more readable format.
Create Strapi Cron Job Log Service
The cron job log Service is responsible for recording every run of a job, when it starts, when it finishes, and whether it succeeded or failed.
This is where the job’s execution status and history get stored, to make them easy to review and track their behavior over time.
To set this up, open the src/api/job-log/services/job-log.ts
file and modify the file:
1// Path: ./src/api/job-log/services/job-log.ts
2
3import { factories } from "@strapi/strapi";
4import { CRON_JOB_UID, JOB_LOG_UID } from "../../../utils/helpers";
5
6const logDoc = () => strapi.documents(JOB_LOG_UID);
7const jobDoc = () => strapi.documents(CRON_JOB_UID);
8
9export default factories.createCoreService(
10 "api::job-log.job-log",
11 ({ strapi }) => ({
12 async createRunLog(job: any) {
13 const now = new Date();
14 const jobId = job.options.meta.jobId;
15 const manualTrigger = job.options.meta?.manualTrigger ?? false;
16
17 await strapi.db.transaction(async ({ trx }) => {
18 await strapi.db
19 .connection("cron_jobs")
20 .transacting(trx)
21 .where("document_id", jobId)
22 .update({
23 last_run_at: new Date(),
24 })
25 .increment("run_count", 1);
26
27 const log = await strapi.documents(JOB_LOG_UID).create({
28 data: {
29 cron_job: jobId,
30 startedAt: now,
31 manualTrigger,
32 jobStatus: "running",
33 executionId: job.options.meta.executionId,
34 },
35 });
36
37 job.options.meta.logId = log.documentId;
38 job.options.meta.startedAt = now;
39 });
40 },
41
42 async jobSuccessLog(job: any) {
43 const now = new Date();
44 const logId = job.options.meta.logId;
45
46 await strapi.db.transaction(async () => {
47 await logDoc().update({
48 documentId: logId,
49 data: {
50 endedAt: now,
51 durationMs: now.getTime() - job.options.meta.startedAt.getTime(),
52 jobStatus: "success",
53 },
54 });
55
56 await updateJob(job);
57 });
58 },
59
60 async jobErrorLog(job: any, error: Error) {
61 const now = new Date();
62 const logId = job.options.meta.logId;
63
64 await strapi.db.transaction(async () => {
65 await logDoc().update({
66 documentId: logId,
67 data: {
68 endedAt: now,
69 durationMs: now.getTime() - job.options.meta.startedAt.getTime(),
70 jobStatus: "error",
71 message: error?.message,
72 errorStack: error?.stack,
73 },
74 });
75
76 await updateJob(job);
77 });
78 },
79 })
80);
81
82async function updateJob(job: any) {
83 await jobDoc().update({
84 documentId: job.options.meta.jobId,
85 data: {
86 nextRunAt: job?.job?.nextInvocation().toISOString(),
87 },
88 });
89}
In the code above, when a job starts, it creates a run log entry, marks the job as running
, and increments its run counter.
When the job finishes, it captures the end time and duration, marking the status as either success
or error
, along with any error messages and traceback information.
It also updates the parent job with its next scheduled run time.
Creating Strapi Utility Helpers for Running Cron Jobs
Before we move on to creating and registering jobs in Strapi, we'll need to set up a few utilities that will make the job execution process cleaner and more maintainable.
These helpers will contain common logic like generating delays, converting names, finding jobs, and invoking jobs reliably.
Create the file: src/utils/helpers.ts
, and add this code:
Step 1. Create Helper Functions
1// Path: ./src/utils/helpers.ts
2
3export function randomDelay(minMs = 2000, maxMs = 10000): Promise<void> {
4 const delayTime = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
5 return new Promise((resolve) => setTimeout(resolve, delayTime));
6}
7
8export function camelToTitle(text: string): string {
9 return text
10 .replace(/([A-Z])/g, " $1")
11 .replace(/^./, (char) => char.toUpperCase())
12 .trim();
13}
14
15export const findStrapiJob = (jobName: string) =>
16 strapi.cron.jobs.find((job) => job.name === jobName);
17
18export const CRON_JOB_UID = "api::cron-job.cron-job";
19export const JOB_LOG_UID = "api::job-log.job-log";
In the code above, we have the following:
randomDelay
: Simulates delays for testing or throttling jobs.camelToTitle
: Transforms camelCase names (e.g., sendEmailJob) into readable titles (e.g., Send Email Job).findStrapiJob
: Finds a registered job in Strapi.CRON_JOB_UID
andJOB_LOG_UID
: Provide static identifiers for working with the job and job log collections.
Step 2: Create Task Handler Helper Function
Next, create the file: src/utils/task-handler.ts
and add this code:
1import type { Core } from "@strapi/strapi";
2import { findStrapiJob, JOB_LOG_UID } from "./helpers";
3import { randomUUID } from "node:crypto";
4
5type TaskFn = (context: {
6 strapi: Core.Strapi;
7 runtimeOptions?: Record<string, any>;
8}) => Promise<unknown>;
9
10interface TaskConfig {
11 taskName: string;
12 fn: TaskFn;
13}
14
15export default function taskHandler({ taskName, fn }: TaskConfig) {
16 return async function task(runtimeOptions?: { manualTrigger?: boolean }) {
17 const job = findStrapiJob(taskName);
18 const manualTrigger = runtimeOptions?.manualTrigger ?? false;
19
20 job.options.meta.executionId = randomUUID();
21 job.options.meta.manualTrigger = manualTrigger;
22
23 await strapi.service(JOB_LOG_UID).createRunLog(job);
24
25 try {
26 const result = await fn({ strapi, runtimeOptions });
27 await strapi.service(JOB_LOG_UID).jobSuccessLog(job);
28
29 return result;
30 } catch (err) {
31 await strapi.service(JOB_LOG_UID).jobErrorLog(job, err);
32 }
33 };
34}
The taskHandler
serves as the unified execution entry point for jobs, which makes it the central piece of our background job system.
Here’s how the taskHandler
function above operates:
- It looks up the running job using its name by calling the
findStrapiJob
function. - A unique
executionId
is generated for every run usingrandomUUID()
, to allow us to track each execution in the logs. - The
manualTrigger
status is captured fromruntimeOptions
. This enables us to differentiate automated versus manual triggers. - Before invoking the actual job handler (
fn
), it logs the start of the run by callingstrapi.service(JOB_LOG_UID).createRunLog
. - Upon successful execution of the job, it logs the success by calling
jobSuccessLog
. - If any error occurs during the execution, it captures the error and its traceback by invoking
jobErrorLog
, which stores the debug information. - Every job we define is wrapped in a consistent lifecycle, which makes it predictable, trackable, and manageable.
Step 3: Create Task Runner Function
Finally, create the file: src/task-runner.ts
and add this code:
1import tasks from "../../config/cron-tasks";
2
3export async function runTaskByName(
4 taskName: string,
5 runtimeOptions?: Record<string, any>
6) {
7 const taskEntry = tasks[taskName];
8 if (!taskEntry) {
9 throw new Error(`Task "${taskName}" not found.`);
10 }
11 await taskEntry.task(runtimeOptions);
12}
The runTaskByName()
function provides a convenient way to trigger any registered job by name. We will use this function to trigger jobs manually through the frontend.
Creating and Registering Strapi Cron Jobs on Server Boot
At this stage, we’ve defined our content types and set up utilities to support job execution.
Now we need to define actual cron jobs and ensure that these jobs are dynamically registered every time Strapi starts up.
In this step, we will:
- Define cron job configurations
- Enable Strapi’s cron engine
- Register and synchronize jobs with the database at runtime, to enable persistence across restarts
Step 1: Define Tasks for Strapi Cron Jobs
Navigate to the /config
directory and create a new file named cron-tasks.ts
. Add the following task definition codes:
1import type { Core } from "@strapi/strapi";
2import { randomDelay } from "../src/utils/helpers";
3import taskHandler from "../src/utils/task-handler";
4
5export default {
6 healthCheckPing: {
7 task: taskHandler({
8 taskName: "healthCheckPing",
9 fn: async ({ strapi }: { strapi: Core.Strapi }) => {
10 if (Math.random() < 0.5) {
11 throw new Error("Something went wrong!");
12 } else {
13 await randomDelay(1000);
14 strapi.log.info("Pinging health-check...");
15 }
16 },
17 }),
18 options: {
19 rule: "*/2 * * * *",
20 tz: "Africa/Lagos",
21 meta: {
22 displayName: "Health Check Ping",
23 description: "Performs health-check ping every 5 minutes",
24 tags: ["health-check", "ping"],
25 enabled: true,
26 },
27 },
28 },
29
30 sendWeeklyDigest: {
31 task: taskHandler({
32 taskName: "sendWeeklyDigest",
33 fn: async ({ strapi }) => {
34 await randomDelay();
35 strapi.log.info("Sending weekly digest email...");
36 },
37 }),
38 options: {
39 rule: "0 8 * * 5",
40 meta: {
41 displayName: "Send Weekly Digest",
42 description: "Send digest emails every friday at 8am",
43 tags: ["digest", "weekly"],
44 enabled: true,
45 },
46 },
47 },
48
49 syncCRMData: {
50 task: taskHandler({
51 taskName: "syncCRMData",
52 fn: async ({ strapi }) => {
53 await randomDelay();
54 strapi.log.info("Syncing CRM data...");
55 },
56 }),
57 options: {
58 rule: "*/10 * * * *",
59 tz: "Africa/Lagos",
60 meta: {
61 displayName: "Sync CRM Data",
62 description: "Sync data from external CRM",
63 tags: ["crm", "sync"],
64 enabled: true,
65 },
66 },
67 },
68
69 generateFinancialReports: {
70 task: taskHandler({
71 taskName: "generateFinancialReports",
72 fn: async ({ strapi }) => {
73 await randomDelay();
74 strapi.log.info("Generating financial reports...");
75 },
76 }),
77 options: {
78 rule: "0 8 * * 5",
79 meta: {
80 tags: ["finance", "sync"],
81 enabled: false,
82 },
83 },
84 },
85
86 generateUsageReports: {
87 task: taskHandler({
88 taskName: "generateUsageReports",
89 fn: async ({ strapi }) => {
90 await randomDelay();
91 strapi.log.info("Generating usage reports...");
92 },
93 }),
94 options: {
95 rule: "*/2 * * * *",
96 meta: {
97 tags: ["crm", "sync"],
98 enabled: false,
99 },
100 },
101 },
102};
The code above defines several demo tasks, like sending a weekly digest or syncing CRM data, with a random delay added to simulate actual operation time.
While the exact work each task does isn’t the focus here, what is important is how we describe and manage these tasks using the meta
object inside their options.
The meta
object is a custom property where we store useful information about the task; it also serves as a marker for manageable tasks. It holds details such as:
displayName
: A human-friendly name for the task, for example "Health Check Ping". This makes it easier to identify the task when managing or displaying it on the UI.description
: A short summary of what the task does.tags
: Keywords or categories related to the task. These can help with filtering or organizing tasks in a UI.enabled
: A flag (true
orfalse
) that indicates whether the task is currently active or not.
The meta
object is a flexible way to enrich task definitions with data that supports both management features and operational logic, with the ability to shape it further as our needs grow.
Step 2: Enable Strapi Cron Jobs
Open the config/server.ts
file in the Strapi project and add the cron
object like the following:
1import cronTasks from "./cron-tasks";
2
3export default ({ env }) => ({
4 host: env("HOST", "0.0.0.0"),
5 port: env.int("PORT", 1337),
6 app: {
7 keys: env.array("APP_KEYS"),
8 },
9 cron: {
10 enabled: true,
11 tasks: cronTasks,
12 },
13});
This configuration enables Strapi’s built-in cron job system and tells Strapi to automatically load and run the tasks we defined in the cron-tasks.ts
file.
It hooks our task definitions into the Strapi app so they can run on the schedule we’ve set.
Step 3: Register and Maintain Strapi Cron Jobs with Strapi Lifecycle Functions
In this step, we will configure how jobs are registered and kept in sync every time the server starts using the bootstrap
Strapi lifecycle function.
We will make sure that any jobs we’ve defined in config/cron-tasks.ts
are automatically added, updated, or removed based on their status in the database.
Open the src/index.ts
file and modify it as below:
1import type { Core } from "@strapi/strapi";
2import { CRON_JOB_UID, findStrapiJob } from "./utils/helpers";
3import cronTasks from "../config/cron-tasks";
4
5export default {
6 register(/* { strapi }: { strapi: Core.Strapi } */) {},
7
8 async bootstrap({ strapi }: { strapi: Core.Strapi }) {
9 const jobService = strapi.service(CRON_JOB_UID);
10
11 const tasks = cronTasks;
12
13 for (const taskName of Object.keys(tasks)) {
14 const job = findStrapiJob(taskName);
15
16 if (!job.options.meta) continue;
17
18 await processJob(job, jobService);
19 }
20 },
21};
22
23async function processJob(job, jobService: Core.Service) {
24 if (!job.options.meta) return false;
25
26 let existingJob = await jobService.findJob(job.name);
27
28 if (!existingJob) {
29 existingJob = await jobService.createJob(job);
30 }
31
32 if (existingJob.isDeleted) {
33 strapi.cron.remove(job.name);
34 return false;
35 }
36
37 if (!existingJob.enabled) {
38 strapi.cron.remove(job.name);
39 return false;
40 }
41
42 job.options = JSON.parse(JSON.stringify(existingJob.options));
43 job.options.meta.jobId = existingJob.documentId;
44
45 return true;
46}
Here is what we did above:
- The
bootstrap
function is where all our jobs come to life when the server starts. We make sure that every job we’ve defined inconfig/cron-tasks.ts
is properly registered, kept in sync with the database, and configured for how it should run. - When the server boots, the code goes through each of the jobs defined in
config/cron-tasks.ts
and finds their corresponding database entries. If a job doesn’t already exist in the database, it creates one. This means we can add new jobs simply by adding them to theconfig/cron-tasks.ts
file, and the server will take care of persisting them automatically. - At the same time, the process is smart enough to respect jobs that have been deleted or intentionally disabled in the database. If a job has been marked as deleted or disabled, it’s removed from the scheduler.
- Each job is linked to its database entry through a
jobId
in themeta
object, to make it possible to track its status, run it manually, or build more advanced scheduling and monitoring features if we need to.
Creating Strapi Cron Job Log Controllers, and Routes
With the job definitions, services, and bootstrapping now in place, the next step is to make these jobs and their logs available through Strapi’s REST API.
In this section, we’ll create custom controllers and routes for both jobs and job logs. These will form the bridge between the backend services and the Next.js client application we'll build later.
Step 1: Creating the Strapi Cron Job Controller
The Strapi cron job controller is where we define the REST endpoints for managing jobs. From triggering them manually to updating, deleting, toggling, or rescheduling them.
To set this up, open the following file:src/api/cron-job/controllers/cron-job.ts
and update the code with this:
1import { factories } from "@strapi/strapi";
2import { CRON_JOB_UID, findStrapiJob } from "../../../utils/helpers";
3import { runTaskByName } from "../../../utils/task-runner";
4import cronTasks from "../../../../config/cron-tasks";
5
6export default factories.createCoreController(
7 "api::cron-job.cron-job",
8 ({ strapi }) => ({
9 async triggerJob(ctx) {
10 const { name } = ctx.params;
11 const task = findStrapiJob(name);
12
13 await runTaskByName(name, { manualTrigger: true });
14
15 return ctx.send({ name: task.name, success: true });
16 },
17
18 async update(ctx) {
19 const { name, displayName, description } = ctx.request.body.data;
20 const task = findStrapiJob(name);
21
22 task.options.meta.displayName = displayName;
23 task.options.meta.description = description;
24
25 await strapi.documents(CRON_JOB_UID).update({
26 documentId: task.options.meta.jobId,
27 data: {
28 displayName,
29 description,
30 },
31 });
32
33 return ctx.send({ name: task.name, success: true });
34 },
35
36 async delete(ctx) {
37 const { id } = ctx.params;
38
39 const jobDoc = await strapi.documents(CRON_JOB_UID).findOne({
40 documentId: id,
41 });
42
43 const task = findStrapiJob(jobDoc.name);
44
45 if (task) strapi.cron.remove(task.name);
46
47 const job = await strapi.documents(CRON_JOB_UID).update({
48 documentId: id,
49 data: {
50 IsDeleted: true,
51 },
52 });
53
54 return ctx.send(job);
55 },
56
57 async toggleJobEnabled(ctx) {
58 const { name } = ctx.params;
59 const { enabled: value } = ctx.request.body;
60
61 let job = await strapi.documents(CRON_JOB_UID).findFirst({
62 filters: {
63 name,
64 },
65 });
66
67 if (!value) {
68 strapi.cron.remove(job.name);
69 } else {
70 const task = cronTasks[name];
71 task.options = JSON.parse(JSON.stringify(job.options));
72 task.options.meta.jobId = job.documentId;
73 strapi.cron.add({ [name]: task });
74 }
75
76 job = await strapi.documents(CRON_JOB_UID).update({
77 documentId: job.documentId,
78 data: {
79 enabled: value,
80 },
81 });
82
83 return ctx.send(job);
84 },
85
86 async reschedule(ctx) {
87 const { name, schedule } = ctx.request.body;
88 const task = findStrapiJob(name);
89
90 try {
91 const success = await task.job?.reschedule(schedule);
92
93 if (success) {
94 await strapi.documents(CRON_JOB_UID).update({
95 documentId: task.options.meta.jobId,
96 data: {
97 schedule,
98 nextRunAt: task.job?.nextInvocation().toISOString(),
99 },
100 });
101
102 return ctx.send({ message: "Job rescheduled", success });
103 }
104
105 ctx.send({ message: "Failed to reschedule job", success });
106 } catch (err) {
107 strapi.log.error("Failed to reschedule job", err);
108 return ctx.internalServerError("Could not reschedule job");
109 }
110 },
111
112 async findOne(ctx) {
113 const { id } = ctx.params;
114
115 const entity = await strapi.documents(CRON_JOB_UID).findOne({
116 documentId: id,
117 filters: {
118 IsDeleted: false,
119 },
120 });
121
122 return ctx.send(entity);
123 },
124
125 async find(ctx) {
126 const sanitizedQueryParams = await this.sanitizeQuery(ctx);
127
128 const jobs = await strapi.documents(CRON_JOB_UID).findMany({
129 populate: {
130 job_logs: {
131 fields: ["jobStatus"],
132 filters: {
133 jobStatus: "running",
134 },
135 },
136 },
137 filters: {
138 IsDeleted: false,
139 },
140 sort: { createdAt: "desc" },
141 ...sanitizedQueryParams,
142 });
143
144 return ctx.send(jobs);
145 },
146 })
147);
This controller is the core interface between our job scheduler and our frontend application.
Here is what the controller above does:
- It lets us manually trigger a job using the
triggerJob
method. We will use this endpoint to run a task on demand without waiting for its scheduled time. When a job is triggered manually, the system logs whether it succeeded or failed, just like a scheduled run. - The
update
method allows us to change a job’s display name or description. These updates are stored in the database and reflected in the job’s metadata. - The
delete
method marks a job as deleted in the database and removes it from the scheduler. The job remains in the records for reference, but it no longer runs. - The
toggleJobEnabled
method makes it easy to enable or disable a job. If we disable a job, it is removed from the scheduler and won’t run again until it is re-enabled. If we enable it, the scheduler picks it back up using the stored job definition. - The
reschedule
method lets us update a job’s schedule. It reschedules the job on the fly and updates the database with the new schedule and next run time. - Finally, the
findOne
andfind
methods let us fetch job information. We can retrieve details about a single job or a list of all jobs, including their statuses and recent logs.
Step 2: Creating the Strapi Cron Job Log Controller
The cron job log Controller provides an API endpoint that allows our frontend app to fetch the logs for a specific job. This is key for monitoring job activity, as it lets us view the history of each job’s runs, including their statuses and timestamps.
Open: src/api/job-log/controllers/job-log.ts
and update the code as follows:
1import { factories } from "@strapi/strapi";
2import { JOB_LOG_UID } from "../../../utils/helpers";
3
4export default factories.createCoreController(
5 "api::job-log.job-log",
6 ({ strapi }) => ({
7 async findByJob(ctx) {
8 try {
9 const { jobId, page = 1, pageSize = 10 } = ctx.query;
10
11 if (!jobId) {
12 return ctx.badRequest("Missing jobId parameter");
13 }
14
15 const logs = await strapi.documents(JOB_LOG_UID).findMany({
16 filters: {
17 cron_job: {
18 documentId: jobId,
19 },
20 },
21 sort: { createdAt: "desc" },
22 start: Number(page),
23 limit: Number(pageSize),
24 });
25
26 return ctx.send(logs);
27 } catch (error) {
28 console.error("Error fetching job logs:", error);
29 return ctx.internalServerError("Failed to fetch job logs");
30 }
31 },
32 })
33);
The findByJob
action gives our application a way to request a paginated list of job logs for any specific job
.
Step 3: Adding Custom Routes
To connect our custom job and log controllers to the outside world, we will define specific REST API routes. These routes will make it possible for our frontend application to interact with the Strapi backend.
1. Cron Job Routes
First, create the custom.ts file for the cron jobs:
src/api/cron-job/routes/custom.ts
Add the following code:
1export default {
2 routes: [
3 {
4 method: "POST",
5 path: "/cron-jobs/trigger/:name",
6 handler: "api::cron-job.cron-job.triggerJob",
7 },
8 {
9 method: "POST",
10 path: "/cron-jobs/toggle-enabled/:name",
11 handler: "api::cron-job.cron-job.toggleJobEnabled",
12 },
13 {
14 method: "POST",
15 path: "/cron-jobs/reschedule",
16 handler: "api::cron-job.cron-job.reschedule",
17 },
18 ],
19};
These routes provide endpoints for triggering jobs manually, enabling or disabling them, and updating their schedules.
2. Job Log Routes
Create the file: src/api/job-log/routes/custom.ts
and add:
1export default {
2 routes: [
3 {
4 method: "GET",
5 path: "/job-logs/by-job",
6 handler: "api::job-log.job-log.findByJob",
7 },
8 ],
9};
This route lets clients request the logs for a specific job.
Enabling Endpoints for Public Access
By default, Strapi protects its endpoints by allowing only authorized access. To make the job
and log
endpoints available publicly for our Next.js app to call them, we’ll need to configure their permissions in the Strapi admin panel.
1. Open the Strapi Admin Panel
Go to http://localhost:1337/admin
and log in with an admin account.
2. Navigate to Roles & Permissions
In the sidebar, click Settings > Users & Permissions > Roles. Here you’ll see available roles like Authenticated and Public.
3. Edit the Public Role
Click on the Public role to open its permission settings.
4. Enable the Desired Endpoints
Scroll down to locate the sections for:
- Cron Job
- Job Log
Expand Cron Job and enable the endpoints for the following to make them publicly available:
find
findOne
triggerJob
toggleJobEnabled
reschedule
delete
Check the boxes next to the methods in the list above to expose them.
Expand the Job Log permission and enable the following:
findByJob
5. Save Changes
After selecting the endpoints, click Save to apply the changes.
The endpoint should look like this:
Github Source Code
The complete code for this tutorial can be found on GitHub:
Final Thoughts: Persistent Job Scheduling in Strapi
We’ve laid the foundation for a fully persistent, runtime-managed background job system in Strapi 5. In this first part of the series, we’ve learned how to:
- Create content types to store jobs and their run history
- Build helpers to manage job execution and handle errors
- Automatically register and manage cron jobs when the server starts
- Set up services to create jobs and log their status
- Add controller methods to trigger, pause, reschedule, and monitor cron jobs
In the next article, we’ll shift focus to the frontend. We’ll learn how to build a Next.js Dashboard that:
- Displays available jobs and their status
- Shows execution logs, including error messages and timings
- Enables manual triggering, toggling, and rescheduling jobs from a friendly interface
See you in the Part 2 of this tutorial!
Emeka is a skilled software developer and educator in web development, mentoring, and collaborating with teams to deliver business-driven solutions.