Skip to main content

Scheduled Publishing

Strapi 5 includes scheduled publishing through Releases on Growth and Enterprise plans. For projects that need free/self-hosted per-entry scheduling, custom rules, or scheduled unpublishing, you can also implement scheduling with Strapi cron jobs. This page explains both paths, the draft/published duality that makes naive custom approaches fail, and advanced patterns like scheduled unpublishing, editorial review, and timezone handling.

Built-in option: Releases​

Releases group entries from multiple content types and locales, then publish or unpublish the whole batch manually or at a scheduled date and timezone. Use this first when your project is on a Growth or Enterprise plan and editors can work with release containers instead of a per-entry scheduledPublishAt field.

Key characteristics:

  • Available on Growth and Enterprise plans.
  • Requires Draft & Publish on the included content types.
  • A scheduled release publishes the latest saved draft at release time; it does not freeze a snapshot of the entry when the entry is added to the release.
  • Enterprise adds audit-log entries for release actions.

The cron-based approach below is still useful when Releases are unavailable, too coarse-grained, or when you need custom rules such as scheduled unpublishing of individual entries.

The problem: draft/published duality​

In Strapi 5, every document can have two versions simultaneously: a draft and a published version. When you publish a document, the draft row remains in the database as a shadow copy.

This means:

// Returns ALL drafts, including shadow drafts of published documents
const drafts = await strapi.documents('api::article.article').findMany({
status: 'draft',
});
// ^ This includes articles that are ALREADY published!

You cannot use status: 'draft' alone to find "unpublished" documents. Every published document also has a draft.


Solution: dedicated scheduledPublishAt field​

Instead of relying on publish status, use a dedicated datetime field as the single source of truth:

  • If scheduledPublishAt is set and in the past -> publish the document
  • After publishing -> clear the field
  • The field being null means "no scheduled publish pending"

This completely sidesteps the draft duality issue.

Step 1: add the field to your content type​

In the Content-Type Builder, add a scheduledPublishAt field of type DateTime to every content type that needs scheduling. Or add it directly in the schema:

File: src/api/article/content-types/article/schema.json

{
"attributes": {
"title": { "type": "string", "required": true },
"content": { "type": "richtext" },
"scheduledPublishAt": {
"type": "datetime"
}
}
}

Step 2: create the cron job​

// config/cron-tasks.js
module.exports = {
scheduledPublish: {
task: async ({ strapi }) => {
await publishScheduled(strapi);
},
options: {
rule: '*/5 * * * *',
tz: 'UTC',
},
},
};
// config/server.js
const cronTasks = require('./cron-tasks');

module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
cron: {
enabled: true,
tasks: cronTasks,
},
});
async function publishScheduled(strapi) {
const now = new Date().toISOString();

// All content types that support scheduled publishing
const schedulableTypes = [
'api::article.article',
'api::page.page',
// add more as needed
];

for (const uid of schedulableTypes) {
const documents = await strapi.documents(uid).findMany({
status: 'draft',
filters: {
scheduledPublishAt: {
$lte: now,
$notNull: true,
},
},
fields: ['title', 'scheduledPublishAt'],
});

for (const doc of documents) {
try {
// Clear the scheduled date and publish the draft in one operation.
await strapi.documents(uid).update({
documentId: doc.documentId,
data: { scheduledPublishAt: null },
status: 'published',
});

strapi.log.info(
`[scheduled-publish] Published ${uid} "${doc.title || doc.documentId}"`
);
} catch (error) {
strapi.log.error(
`[scheduled-publish] Failed to publish ${uid} ${doc.documentId}:`,
error
);
}
}
}
}

Why this works for all scenarios​

ScenarioWhat happens
New draft, never publishedupdate({ status: 'published' }) creates the published version. Correct.
Already published, editor schedules an updateupdate({ status: 'published' }) republishes the latest draft. Correct.
Published, no scheduledPublishAt setNot picked up by the filter. Correct.
Already processed by cronscheduledPublishAt was cleared. Not picked up again. Correct.

If you only need to query "never published" or "modified since publish" drafts, prefer Strapi's publicationFilter instead of inferring it from status: 'draft'.

If your cron does not need to mutate fields before publishing, use the explicit operation instead: await strapi.documents(uid).publish({ documentId: doc.documentId, locale: doc.locale }). The update({ status: 'published' }) form above is useful when clearing scheduler metadata and publishing in one call.


Scheduled unpublishing​

The same pattern works for scheduled unpublishing (e.g., time-limited promotions):

Add this to schema.json:

{
"attributes": {
"scheduledPublishAt": { "type": "datetime" },
"scheduledUnpublishAt": { "type": "datetime" }
}
}
// config/server.js - extend the cron task
'*/5 * * * *': async ({ strapi }) => {
await publishScheduled(strapi);
await unpublishScheduled(strapi);
},

async function unpublishScheduled(strapi) {
const now = new Date().toISOString();

const schedulableTypes = [
'api::article.article',
'api::page.page',
];

for (const uid of schedulableTypes) {
// For unpublishing, we query the PUBLISHED status
const documents = await strapi.documents(uid).findMany({
status: 'published',
filters: {
scheduledUnpublishAt: {
$lte: now,
$notNull: true,
},
},
fields: ['title', 'scheduledUnpublishAt'],
});

for (const doc of documents) {
try {
await strapi.documents(uid).unpublish({
documentId: doc.documentId,
});
await strapi.documents(uid).update({
documentId: doc.documentId,
data: { scheduledUnpublishAt: null },
});

strapi.log.info(
`[scheduled-unpublish] Unpublished ${uid} "${doc.title || doc.documentId}"`
);
} catch (error) {
strapi.log.error(
`[scheduled-unpublish] Failed to unpublish ${uid} ${doc.documentId}:`,
error
);
}
}
}
}

Extracting into a reusable service​

For cleaner code, move the scheduling logic into a dedicated service:

// src/api/scheduler/services/scheduler.js
module.exports = ({ strapi }) => ({

schedulableTypes: [
'api::article.article',
'api::page.page',
],

async processScheduledPublish() {
const now = new Date().toISOString();
let totalPublished = 0;

for (const uid of this.schedulableTypes) {
const documents = await strapi.documents(uid).findMany({
status: 'draft',
filters: {
scheduledPublishAt: { $lte: now, $notNull: true },
},
fields: ['title', 'scheduledPublishAt'],
});

for (const doc of documents) {
try {
await strapi.documents(uid).update({
documentId: doc.documentId,
data: { scheduledPublishAt: null },
status: 'published',
});
totalPublished++;
strapi.log.info(`[scheduler] Published ${uid} "${doc.title}"`);
} catch (error) {
strapi.log.error(`[scheduler] Publish failed: ${uid} ${doc.documentId}`, error);
}
}
}

return totalPublished;
},

async processScheduledUnpublish() {
const now = new Date().toISOString();
let totalUnpublished = 0;

for (const uid of this.schedulableTypes) {
const documents = await strapi.documents(uid).findMany({
status: 'published',
filters: {
scheduledUnpublishAt: { $lte: now, $notNull: true },
},
fields: ['title', 'scheduledUnpublishAt'],
});

for (const doc of documents) {
try {
await strapi.documents(uid).unpublish({
documentId: doc.documentId,
});
await strapi.documents(uid).update({
documentId: doc.documentId,
data: { scheduledUnpublishAt: null },
});
totalUnpublished++;
strapi.log.info(`[scheduler] Unpublished ${uid} "${doc.title}"`);
} catch (error) {
strapi.log.error(`[scheduler] Unpublish failed: ${uid} ${doc.documentId}`, error);
}
}
}

return totalUnpublished;
},

async runAll() {
const published = await this.processScheduledPublish();
const unpublished = await this.processScheduledUnpublish();
return { published, unpublished };
},
});

The cron job becomes a one-liner:

// config/server.js
const cronTasks = require('./cron-tasks');

module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
cron: {
enabled: true,
tasks: cronTasks,
},
});
// config/cron-tasks.js
module.exports = {
scheduledPublishing: {
task: async ({ strapi }) => {
await strapi.service('api::scheduler.scheduler').runAll();
},
options: {
rule: '*/5 * * * *',
tz: 'UTC',
},
},
};

Preventing manual publish of scheduled content​

Editors might accidentally click "Publish" on a document that's scheduled for the future. Guard against this with a Document Service middleware:

// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.action !== 'publish') {
return next();
}

const docId = context.params.documentId;
const uid = context.uid;

// Check if this document has a future scheduled date
const draft = await strapi.documents(uid).findOne({
documentId: docId,
status: 'draft',
fields: ['scheduledPublishAt'],
});

if (draft?.scheduledPublishAt) {
const scheduledDate = new Date(draft.scheduledPublishAt);

if (scheduledDate > new Date()) {
throw new Error(
`This document is scheduled for publication at ${scheduledDate.toISOString()}. ` +
`Remove the scheduled date first to publish manually.`
);
}
}

return next();
});
},
};

Optional: expose a manual trigger endpoint​

Useful for testing or for external systems (CI/CD, webhooks) to trigger scheduled publishing on demand:

// src/api/scheduler/routes/scheduler.js
module.exports = {
routes: [
{
method: 'POST',
path: '/scheduler/run',
handler: 'api::scheduler.scheduler.trigger',
config: {
policies: [
{
name: 'global::has-role',
config: { roles: ['admin'] },
},
],
},
},
{
method: 'GET',
path: '/scheduler/pending',
handler: 'api::scheduler.scheduler.pending',
config: {
policies: [
{
name: 'global::has-role',
config: { roles: ['admin', 'editor'] },
},
],
},
},
],
};
// src/api/scheduler/controllers/scheduler.js
module.exports = {
async trigger(ctx) {
const result = await strapi.service('api::scheduler.scheduler').runAll();
ctx.body = {
message: `Published ${result.published}, unpublished ${result.unpublished}`,
...result,
};
},

async pending(ctx) {
const now = new Date().toISOString();
const types = strapi.service('api::scheduler.scheduler').schedulableTypes;
const pending = {};

for (const uid of types) {
const toPublish = await strapi.documents(uid).findMany({
status: 'draft',
filters: {
scheduledPublishAt: { $notNull: true },
},
fields: ['title', 'scheduledPublishAt'],
});

const toUnpublish = await strapi.documents(uid).findMany({
status: 'published',
filters: {
scheduledUnpublishAt: { $notNull: true },
},
fields: ['title', 'scheduledUnpublishAt'],
});

if (toPublish.length || toUnpublish.length) {
pending[uid] = {
pendingPublish: toPublish.map(d => ({
documentId: d.documentId,
title: d.title,
scheduledPublishAt: d.scheduledPublishAt,
overdue: new Date(d.scheduledPublishAt) <= new Date(now),
})),
pendingUnpublish: toUnpublish.map(d => ({
documentId: d.documentId,
title: d.title,
scheduledUnpublishAt: d.scheduledUnpublishAt,
overdue: new Date(d.scheduledUnpublishAt) <= new Date(now),
})),
};
}
}

ctx.body = { data: pending };
},
};

Timezone handling​

Strapi stores all dates in UTC. If your editors work in different timezones, make sure the frontend converts local time to UTC before sending it to the API:

// Frontend: convert local datetime input to UTC ISO string
const localDate = new Date('2025-03-15T09:00:00'); // user's local time
const utcString = localDate.toISOString(); // '2025-03-15T08:00:00.000Z' (CET -> UTC)
const authHeader = getEditorAuthHeader();

await fetch('/api/articles/abc123', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
body: JSON.stringify({
data: { scheduledPublishAt: utcString },
}),
});

The cron job compares against new Date().toISOString() which is always UTC, so no conversion is needed on the server side.


Notification on scheduled publish​

Send an email or webhook when a document is auto-published:

// Extend the service
async processScheduledPublish() {
// ... (find and publish logic as above)

for (const doc of documents) {
try {
await strapi.documents(uid).update({
documentId: doc.documentId,
data: { scheduledPublishAt: null },
status: 'published',
});

// Notify via email
const fullDoc = await strapi.documents(uid).findOne({
documentId: doc.documentId,
status: 'published',
populate: ['createdBy'],
});

if (fullDoc?.createdBy?.email) {
await strapi.plugin('email').service('email').send({
to: fullDoc.createdBy.email,
subject: `Published: ${doc.title}`,
html: `<p>Your scheduled content <strong>${doc.title}</strong> has been published.</p>`,
});
}

// Notify via webhook
if (process.env.PUBLISH_WEBHOOK_URL) {
await fetch(process.env.PUBLISH_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'scheduled.publish',
contentType: uid,
documentId: doc.documentId,
title: doc.title,
publishedAt: new Date().toISOString(),
}),
});
}
} catch (error) {
strapi.log.error(`[scheduler] Failed: ${uid} ${doc.documentId}`, error);
}
}
}

Cron interval considerations​

IntervalExpressionUse case
Every minute* * * * *Near real-time publishing (higher DB load)
Every 5 minutes*/5 * * * *Good default for most projects
Every 15 minutes*/15 * * * *Low-traffic sites, less DB overhead
Every hour0 * * * *When exact timing doesn't matter

Keep in mind that the maximum delay equals the cron interval. A 5-minute cron means content publishes within 5 minutes of the scheduled time.

On a multi-instance Strapi deployment, every instance with cron enabled runs the same config/cron-tasks schedule. Use a single cron runner or a distributed lock before enabling scheduled publishing on multiple replicas; see Running Strapi on multiple instances.

Cron jobs can also be added at runtime, for example from bootstrap() in a plugin:

strapi.cron.add({
scheduledPublishing: {
task: async ({ strapi }) => {
await strapi.service('api::scheduler.scheduler').runAll();
},
options: {
rule: '*/5 * * * *',
tz: 'UTC',
},
},
});

publishedAt semantics​

In Strapi 5, publishedAt is null on draft versions and a timestamp on published versions. The Document Service API returns drafts by default, so add status: 'published' when you need the live version. The REST API defaults to published content; pass status=draft to read or write drafts without publishing.


Common pitfalls​

PitfallProblemFix
Filtering by status: 'draft' aloneIncludes shadow drafts of published documentsUse scheduledPublishAt as the discriminator
Not clearing scheduledPublishAt after publishDocument gets re-processed every cron runAlways set it to null after publishing
Cron not enabledJobs never runSet cron.enabled: true in config/server.js
Timezone mismatchContent publishes at the wrong timeAlways store and compare in UTC
No error handling in cronOne failure stops processing remaining documentsWrap each publish in try/catch
Multiple Strapi instancesSame document published by multiple instancesUse a distributed lock (Redis) or run cron on one instance only

See also​