Scheduling social posts sounds simple until you try to build it. You need a place to store drafts, a way to attach a future publish time to each one, a background job that fires at the right moment, and a UI that shows what's queued. Most teams reach for a SaaS tool like Buffer and move on. But if you're already running a headless CMS, you have most of the pieces sitting in front of you.
This tutorial walks through building a working social media scheduler on top of Strapi 5 and Next.js 16. You'll model posts as a content type, add a custom publish_at field, and wire up a cron task that promotes drafts to published entries the moment their scheduled time passes. The frontend is a Next.js App Router dashboard that reads the queue through Strapi's REST API using a typed fetch layer. If you're new to pairing these two frameworks, the Next.js and Strapi 5 beginner's guide covers the fundamentals.
One thing worth calling out early: Strapi's native scheduling works at the batch level through the Releases feature, not per individual entry. A Buffer-style tool needs per-post timing, so you'll build that yourself with a datetime field plus a cron job that calls the Document Service publish() method. That's the core of the whole project.
In brief:
- Model social posts as a Strapi 5 Collection Type with Draft and Publish enabled and a custom
publish_atdatetime field. - Use a cron task plus the Document Service API (
findMany+publish) to promote scheduled drafts automatically. - Fetch the post queue in Next.js 16 Server Components with a typed
fetchwrapper and an API token kept server-side. - Handle errors with
error.tsxandnotFound(), following Next.js production patterns.
What we're building
The end product is a two-part application. The backend is a Strapi 5 project that stores social media posts, each with a title, body, cover image, and a publish_at timestamp. A cron task runs every minute, finds any draft whose scheduled time has passed, and publishes it. The frontend is a Next.js 16 dashboard that lists scheduled and published posts by reading Strapi's REST API.
Splitting the work into two separate projects keeps each side focused on what it does best. The Strapi backend owns your data model, authentication, and the scheduling engine, while the Next.js frontend only reads and renders. The cron task lives entirely inside Strapi, so scheduling keeps working even if the frontend is down or nobody has the dashboard open. That independence is the point: the backend is the source of truth for what publishes and when.
This separation follows the principles of headless architecture, where the content layer and presentation layer operate independently. In this tutorial, the frontend stays read-only. All writes happen through the Admin Panel, where you compose posts, and through the custom schedule endpoint you'll add for on-demand publishing. This keeps the data-access surface small and the API token's job easy to reason about.
On the Strapi side you'll use several v5-specific features: the Document Service API for all programmatic content operations, the Draft and Publish feature to represent queued versus live posts, cron configuration for the scheduler, and API tokens to authenticate the frontend. On the Next.js side you'll use async Server Components, the flattened v5 REST response format, and server-only variables.
What you'll learn:
- Defining a Collection Type schema with relations, media, and a custom datetime field
- Calling the Document Service API from a cron task to publish on schedule
- Consuming Strapi 5's flattened REST responses with typed interfaces in Next.js
- Keeping API tokens off the client with server-only environment variables
- Structuring error handling with
error.tsxandnotFound()
Prerequisites
Pin these versions. The JavaScript ecosystem moves fast, and mixing majors is where builds break.
- Node.js 24.x (Active LTS).
@strapi/strapi5.50.0next16.2.10 (Active LTS)react/react-dom19.2.7typescript6.0.3 (current npmlatest; TypeScript 7.0 RC exists but isn't taggedlatestyet)qs6.15.3 for building Strapi query strings- A code editor and a terminal
You'll also create one API token inside the Strapi Admin Panel. No third-party accounts are required, since SQLite runs locally out of the box. For a broader look at what changed in this major release, see what's new in Strapi 5.
Background knowledge assumed: you're comfortable with TypeScript, REST, and the basics of React Server Components. You don't need prior Strapi experience.
Setting up the Strapi backend
Step 1: Create the Strapi project
Scaffold a new TypeScript project. The v5 CLI detects the package manager you invoke it with, so running it through npx uses npm.
npx create-strapi@latest social-scheduler-backend --typescriptAnswer the prompts (skip the account signup if you like), then start the dev server:
cd social-scheduler-backend
npm run developStrapi opens the Admin Panel at http://localhost:1337/admin. Create your first administrator account when prompted. Everything Strapi scaffolds, the config/, src/, and database/ folders, is left as generated except the files shown below.
Step 2: Define the Post content type
The Post is the heart of the scheduler. Create the schema file at the path Strapi expects for a Collection Type. Note the publish_at datetime field: this is the custom field that drives scheduling, since Strapi's built-in published_at cannot trigger publication. If you want a deeper look at how schemas, relations, and field types work together, the guide on content modeling covers the fundamentals.
// src/api/post/content-types/post/schema.json
{
"kind": "collectionType",
"collectionName": "posts",
"info": {
"singularName": "post",
"pluralName": "posts",
"displayName": "Post"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"body": {
"type": "richtext"
},
"platform": {
"type": "enumeration",
"enum": ["twitter", "linkedin", "mastodon", "facebook"],
"required": true
},
"publish_at": {
"type": "datetime"
},
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"account": {
"type": "relation",
"relation": "manyToOne",
"target": "api::account.account",
"inversedBy": "posts"
}
}
}Setting draftAndPublish to true gives each post the three-status lifecycle (Draft, Modified, Published) that the scheduler relies on. A post sits in draft until its publish_at time passes, then the cron task publishes it. For background on how Strapi handles these states, see the Draft and Publish deep dive.
Step 3: Add the Account content type
Each post belongs to a connected social account. Define a second Collection Type with the inverse side of the relation.
// src/api/account/content-types/account/schema.json
{
"kind": "collectionType",
"collectionName": "accounts",
"info": {
"singularName": "account",
"pluralName": "accounts",
"displayName": "Account"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"handle": {
"type": "string",
"required": true
},
"platform": {
"type": "enumeration",
"enum": ["twitter", "linkedin", "mastodon", "facebook"],
"required": true
},
"posts": {
"type": "relation",
"relation": "oneToMany",
"target": "api::post.post",
"mappedBy": "account"
}
}
}Schema changes require a server restart to register. Restart npm run develop, and both content types appear in the Content Manager. Add an account or two so posts have something to link to.
Step 4: Write the scheduler as a service
Keep the scheduling logic in a service rather than inline in the cron config. That makes it testable and lets you call it from a manual endpoint later. When extending a core service in v5, the core methods wrap the Document Service API, so super.update(documentId, params) and friends already speak documentId. For more on how the Document Service replaces the older lifecycle hooks, see the middleware migration guide.
// src/api/post/services/post.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::post.post', ({ strapi }) => ({
async publishDuePosts() {
const duePosts = await strapi.documents('api::post.post').findMany({
status: 'draft',
filters: {
publish_at: { $lt: new Date() },
},
});
const published = await Promise.all(
duePosts.map((post) =>
strapi.documents('api::post.post').publish({
documentId: post.documentId,
})
)
);
if (published.length > 0) {
strapi.log.info(`Scheduler published ${published.length} post(s).`);
}
return published;
},
}));Two v5 details matter here. First, strapi.documents('api::post.post') is how you reach the Document Service, which replaces the old Entity Service API. Second, findMany() returns drafts by default when no status is passed, which is exactly what you want, but passing status: 'draft' explicitly keeps the intent clear. Each result carries a documentId string, and publish() takes that documentId to promote the draft.
The draft-by-default behavior of findMany() is worth understanding, because it flips the assumption most REST consumers carry. The Document Service returns drafts when you don't pass a status, whereas the REST and GraphQL APIs both return published content by default. For a scheduler, the draft default is convenient: your cron task wants the unpublished queue, and that's exactly what it gets. The filter is where the real work happens. If you dropped the publish_at filter and just fetched all drafts, every draft in the system would publish on the next tick, including posts you never scheduled and half-finished work. The $lt: new Date() comparison is what enforces per-post timing: only drafts whose scheduled moment has already passed match, so each post goes live on the first qualifying cron run after its publish_at time, and not before.
Step 5: Register the cron task
Point a cron task at the service. Create the tasks file first.
// config/cron-tasks.ts
export default {
scheduledPublishing: {
task: async ({ strapi }) => {
await strapi.service('api::post.post').publishDuePosts();
},
options: {
rule: '*/1 * * * *',
},
},
};Then enable cron in the server config and wire the tasks in. Strapi's cron is powered by node-schedule, so the rule format follows standard cron with an optional leading seconds field.
// config/server.ts
import cronTasks from './cron-tasks';
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
cron: {
enabled: true,
tasks: cronTasks,
},
});The rule */1 * * * * reads field by field as minute, hour, day of month, month, and day of week, with */1 in the minute position meaning "every minute" and each * meaning "every value." Running once a minute is a sensible default for a scheduler: it keeps published times accurate to within roughly sixty seconds of the target, which is fine for social posts, without hammering the database. If you need tighter precision you can add a leading seconds field and fire more often, but every tick runs a query and possibly a batch of publishes, so shorter intervals trade accuracy for load. Keep in mind that node-schedule runs in-process, so the cron only fires while the Strapi server is actually running. If the process is down, nothing publishes until it comes back up. For more patterns around scheduling, the Strapi cron jobs best practices guide is a useful reference.
Restart the server. Every minute, the task pulls drafts whose publish_at is in the past and publishes them. Create a post, set publish_at to one minute out, save it as a draft, and watch it flip to Published on the next tick.
Step 6: Add a manual schedule endpoint
Sometimes you want to publish immediately from the frontend rather than waiting for the cron tick. Add a custom controller action and route. Because this is a new action rather than an override of a core action, you handle sanitization yourself. The route filename is prefixed with 01- so it's matched before the auto-generated core routes.
// src/api/post/controllers/post.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::post.post', ({ strapi }) => ({
async schedule(ctx) {
const { documentId } = ctx.params;
const published = await strapi.documents('api::post.post').publish({
documentId,
});
const contentType = strapi.contentType('api::post.post');
const sanitized = await strapi.contentAPI.sanitize.output(
published,
contentType,
{ auth: ctx.state.auth }
);
ctx.body = { data: sanitized };
},
}));// src/api/post/routes/01-custom-post.ts
export default {
routes: [
{
method: 'POST',
path: '/posts/:documentId/schedule',
handler: 'api::post.post.schedule',
config: {
policies: [],
middlewares: [],
},
},
],
};The handler string follows the api::name.controllerName.actionName convention. Because you're calling the Document Service directly instead of extending a core action, strapi.contentAPI.sanitize.output() strips any private fields before the response goes out. For a deeper look at how custom routes map to handlers, see the Strapi docs.
Step 7: Create an API token
The Next.js frontend authenticates with an API token. In the Admin Panel, go to Settings > Global settings > API Tokens > Create new API Token. Give it a name, choose an unlimited duration for local development, and pick Full access (or Custom, with find, findOne, and the custom schedule action enabled). Copy the token immediately; it's only shown once unless you set an encryption key. For a walkthrough of how token-based authentication works with the REST API, see the authenticating requests guide.
To make tokens re-viewable, set an encryptionKey under secrets in config/admin.ts and add ENCRYPTION_KEY to .env.
// config/admin.ts
export default ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
});Test the token with curl before moving to the frontend:
curl 'http://localhost:1337/api/posts' \
-H 'Authorization: Bearer YOUR_API_TOKEN'You should get back a JSON payload with a data array and a meta.pagination object.
Building the Next.js frontend
Step 1: Scaffold the Next.js app
From a separate directory, create the frontend and install qs for query building.
npx create-next-app@latest social-scheduler-frontend --typescript --app
cd social-scheduler-frontend
npm install qs@6.15.3
npm install --save-dev @types/qs@6.15.1Confirm your package.json pins the stack correctly.
// package.json
{
"dependencies": {
"next": "16.2.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"qs": "^6.15.3"
},
"devDependencies": {
"typescript": "^6.0.3",
"@types/node": "^24.0.0",
"@types/react": "^19.0.0",
"@types/qs": "^6.15.1"
},
"engines": {
"node": ">=22.0.0"
}
}Step 2: Set server-only environment variables
The API token must never reach the browser. Any variable without the NEXT_PUBLIC_ prefix stays server-side, available only in Server Components, Route Handlers, and Server Actions. See the Next.js environment variables docs for full details. Create .env.local in the project root (not inside a /src folder, since Next.js loads env files from the parent directory, not from /src).
# .env.local
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-full-access-api-tokenKeep STRAPI_API_TOKEN unprefixed. Adding NEXT_PUBLIC_ would bundle it into client JavaScript and expose it to anyone who opens dev tools.
Step 3: Build a typed data access layer
Consolidate all Strapi reads into one module. The server-only import guard guarantees this file can never be pulled into a Client Component, which is the recommended production pattern for keeping credentials off the client. The interfaces reflect Strapi 5's flattened response format: attributes live directly on the data object, and documentId is a string used for queries.
// src/lib/strapi.ts
import 'server-only';
import qs from 'qs';
export interface StrapiMedia {
documentId: string;
name: string;
url: string;
}
export interface StrapiAccount {
documentId: string;
handle: string;
platform: string;
}
export interface StrapiPost {
id: number;
documentId: string;
title: string;
body: string;
platform: string;
publish_at: string | null;
publishedAt: string | null;
cover: StrapiMedia | null;
account: StrapiAccount | null;
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
const STRAPI_URL = process.env.STRAPI_URL;
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
async function strapiFetch<T>(path: string, query: string): Promise<T> {
const res = await fetch(`${STRAPI_URL}/api/${path}?${query}`, {
headers: {
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
},
next: { revalidate: 30 },
});
if (!res.ok) {
throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getScheduledPosts(): Promise<StrapiListResponse<StrapiPost>> {
const query = qs.stringify(
{
status: 'draft',
fields: ['title', 'body', 'platform', 'publish_at'],
populate: {
cover: { fields: ['name', 'url'] },
account: { fields: ['handle', 'platform'] },
},
filters: {
publish_at: { $notNull: true },
},
sort: ['publish_at:asc'],
pagination: { pageSize: 50 },
},
{ encodeValuesOnly: true }
);
return strapiFetch<StrapiListResponse<StrapiPost>>('posts', query);
}
export async function getPublishedPosts(): Promise<StrapiListResponse<StrapiPost>> {
const query = qs.stringify(
{
status: 'published',
fields: ['title', 'body', 'platform', 'publish_at'],
populate: {
account: { fields: ['handle', 'platform'] },
},
sort: ['publishedAt:desc'],
pagination: { pageSize: 50 },
},
{ encodeValuesOnly: true }
);
return strapiFetch<StrapiListResponse<StrapiPost>>('posts', query);
}The flattened response format is one of the bigger day-to-day differences between v5 and v4. In v4, every entry wrapped its fields inside a nested data.attributes object, so you were forever reaching through post.attributes.title. In v5, attributes sit directly on the data object, so it's just post.title. This flatter shape maps cleanly onto plain TypeScript interfaces like StrapiPost, which makes Server Component code safer: the compiler catches a typo in a field name before the request ever runs, and autocomplete knows what's available. Consolidating every read into one server-only module beats scattering raw fetch calls across components for the same reason it beats scattering SQL: there's a single place to define query shapes, apply the token, and adjust caching. When the schema changes, you update one file. For relation and media fields you'll still reach for populate; field selection only covers scalars. The populate and filtering guide walks through the full query syntax if you need more complex data shapes.
A couple of REST specifics: the status parameter takes 'draft' or 'published' (this replaced v4's publicationState), and fields selection only works on scalar attributes, so relations and media are pulled in with populate. The revalidate option gives you incremental static regeneration so the dashboard stays fresh without re-fetching on every request.
Step 4: Render the dashboard
The dashboard is an async Server Component. It fetches the scheduled and published queues in parallel with Promise.all, then renders both. Server Components run only on the server, so the API token in src/lib/strapi.ts never leaves the machine.
// src/app/page.tsx
import { getScheduledPosts, getPublishedPosts, StrapiPost } from '@/lib/strapi';
function PostCard({ post }: { post: StrapiPost }) {
const scheduledFor = post.publish_at
? new Date(post.publish_at).toLocaleString('en-US', { timeZone: 'UTC' })
: 'No time set';
return (
<li style={{ border: '1px solid #ddd', padding: '1rem', borderRadius: 8 }}>
<strong>{post.title}</strong>
<p>{post.body}</p>
<small>
{post.platform} · {post.account?.handle ?? 'unassigned'} · {scheduledFor} UTC
</small>
</li>
);
}
export default async function DashboardPage() {
const scheduledData = getScheduledPosts();
const publishedData = getPublishedPosts();
const [scheduled, published] = await Promise.all([scheduledData, publishedData]);
return (
<main style={{ maxWidth: 720, margin: '0 auto', padding: '2rem' }}>
<h1>Social Scheduler</h1>
<section>
<h2>Queued ({scheduled.data.length})</h2>
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '1rem' }}>
{scheduled.data.map((post) => (
<PostCard key={post.documentId} post={post} />
))}
</ul>
</section>
<section>
<h2>Published ({published.data.length})</h2>
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '1rem' }}>
{published.data.map((post) => (
<PostCard key={post.documentId} post={post} />
))}
</ul>
</section>
</main>
);
}Times are formatted in UTC so a global team sees consistent scheduling regardless of location.
Step 5: Add error handling
Two failure modes matter. A dead Strapi backend or a bad token throws in the fetch layer, which an error.tsx boundary catches. A missing single post gives a 404, which notFound() handles. Error boundaries must be Client Components.
// src/app/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main style={{ maxWidth: 720, margin: '0 auto', padding: '2rem' }}>
<h2>Couldn't load your scheduled posts.</h2>
<p>Check that the Strapi backend is running and your API token is valid.</p>
<button onClick={() => reset()}>Try again</button>
</main>
);
}For a single-post detail route, use notFound() when Strapi returns a 404. Note that starting in Next.js 15, params in dynamic pages is a Promise you must await.
// src/app/posts/[documentId]/page.tsx
import 'server-only';
import { notFound } from 'next/navigation';
import { StrapiPost } from '@/lib/strapi';
async function getPost(documentId: string): Promise<StrapiPost> {
const res = await fetch(
`${process.env.STRAPI_URL}/api/posts/${documentId}?populate=account`,
{
headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` },
next: { revalidate: 30 },
}
);
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`Failed to load post: ${res.status}`);
const { data } = await res.json();
return data;
}
export default async function PostPage({
params,
}: {
params: Promise<{ documentId: string }>;
}) {
const { documentId } = await params;
const post = await getPost(documentId);
return (
<main style={{ maxWidth: 720, margin: '0 auto', padding: '2rem' }}>
<h1>{post.title}</h1>
<p>{post.body}</p>
<small>
{post.platform} · {post.account?.handle ?? 'unassigned'}
</small>
</main>
);
}notFound() renders the nearest not-found.tsx and returns the TypeScript never type, so code after it never runs. Single documents are fetched at GET /api/:pluralApiId/:documentId, using the documentId string rather than the numeric id.
Putting it all together
Run both services. In the backend directory:
cd social-scheduler-backend
npm run developIn the frontend directory:
cd social-scheduler-frontend
npm run devNow walk the full flow:
- Open the Strapi Admin Panel at
http://localhost:1337/adminand create an Account entry (handle plus platform). - Create a Post: fill in title, body, platform, link the account, and set
publish_atto about two minutes in the future. Save it as a draft (don't publish). - Open the Next.js dashboard at
http://localhost:3000. The post appears under Queued with its scheduled UTC time. - Wait for the cron task to tick. Within a minute of
publish_atpassing, Strapi logsScheduler published 1 post(s).and the entry flips to Published. - Refresh the dashboard (or wait for the 30-second revalidation). The post moves from Queued to Published.
It helps to watch a few places at once while this runs. In the terminal running npm run develop, the cron fires quietly every minute, and on the tick where a due post is found you'll see the Scheduler published 1 post(s). line printed by strapi.log.info. If nothing prints, the query found no due drafts, which usually means publish_at hasn't passed yet. To confirm the transition visually, open the post in the Content Manager: before the tick it shows a Draft status, and after publishing it gains a read-only Published tab alongside the draft. On the frontend, remember that the dashboard caches each read for thirty seconds because of revalidate: 30. So even after Strapi publishes, the queue may keep showing the old state until that window expires. A hard refresh past the cache, or waiting out the thirty seconds, brings the dashboard in line with the backend.
To confirm from the API directly, query drafts and published entries separately:
# scheduled (draft) posts
curl 'http://localhost:1337/api/posts?status=draft' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
# live (published) posts
curl 'http://localhost:1337/api/posts?status=published' \
-H 'Authorization: Bearer YOUR_API_TOKEN'Before the scheduled time, your post shows up in the draft query. After the cron task runs, it shifts to the published query. That's a working Buffer alternative: create once, schedule per post, and let the backend handle timing. This approach gives you full control over your content lifecycle, from draft through scheduled publication and beyond.
Next steps
- Connect real platform APIs. Extend the cron service so that after
publish()succeeds, it posts to the actual platform (X, LinkedIn, Mastodon). Store credentials per account and call each platform's API insidepublishDuePosts(). - Add user-facing auth. Swap the shared API token for the Users and Permissions plugin so each user only sees their own posts, using the JWT login flow and per-user filters.
- Batch scheduling with Releases. For campaign-style launches where many posts go live at once, layer in the native Releases feature alongside the per-post scheduler.
- Deploy the stack. Follow the Strapi deployment docs for the backend and the Next.js docs for the frontend, moving SQLite to Postgres and rotating your API token as a Vercel sensitive variable. For a comparison of hosting options, see the top deployment options roundup.
- Try the official client. For isomorphic code, the
@strapi/clientSDK offers ergonomiccollection()andsingle()helpers, though typed content-type generation isn't available yet.






