Content teams live and die by their publishing schedule. A writer drafts a piece, a reviewer signs off, an editor slots it into next Tuesday's blog window, and somewhere in that handoff a critical post slips through the cracks because someone forgot to hit publish. Spreadsheets and Slack threads don't scale past a handful of contributors, and most editorial tools force your workflow into their model instead of the other way around.
This tutorial walks through building an editorial calendar where a content team manages its pipeline from idea to publication.
Strapi 5 handles the content model, the editorial status workflow, team role-based access control (RBAC), and scheduled auto-publishing through cron tasks. TanStack Start powers the dashboard with type-safe routing and optimistic calendar updates through TanStack Query. Because this is an internal team tool with no public-facing pages, it's a strong fit for TanStack Start, the same reasoning behind our inventory management tutorial.
In brief:
- Strapi 5's Document Service middlewares can extend or modify Document Service methods with custom logic before or after operations, so teams can implement project-specific workflow checks—such as publishing rules, role-based transition restrictions, or preventing skipped intermediate stages—where needed.
- A cron task in
config/cron-tasks.ts, when combined with explicit enabling and reference inconfig/server.ts, can run hourly, find approved content past its scheduled date, and auto-publish it through the Document Service API. - The Users & Permissions plugin provides default roles (Authenticated and Public), with custom roles such as Editor-in-Chief, Writer, and Reviewer creatable via the admin UI or REST API, each configurable with specific permissions.
- TanStack Query's optimistic updates make the Kanban board and calendar feel instant when you drag content between stages or dates.
What We're Building
The app has two primary views. A monthly calendar places each content piece on its scheduled publish date, color-coded by channel (blog, newsletter, social, docs). A Kanban-style pipeline board groups items by status so the team can see what's stuck in review and what's ready to schedule. Both views read from the same Strapi backend through TanStack Start server functions, which keep your API token off the client.
Strapi 5's native Draft & Publish maps directly onto the editorial workflow. The custom status field tracks the richer pipeline, and when a piece reaches its scheduled date, a cron task transitions it to Strapi's published state. The two-state native system and the multi-stage custom enumeration coexist deliberately, since editorial teams need more granularity than a binary draft/published toggle provides.
Role-based views mean a Writer only sees their assigned drafts, a Reviewer sees the queue awaiting review, and the Editor-in-Chief sees the whole board. When the cron task publishes a piece, Strapi stamps publishedAt and exposes the content through the public REST API, so a downstream site can pull it immediately.
What you'll learn:
- Modeling an editorial pipeline with Content-Types, enumerations, and relations in Strapi 5
- Enforcing status transitions with Document Service middlewares instead of lifecycle hooks
- Configuring three editorial roles through the Users & Permissions plugin
- Auto-publishing scheduled content with a cron task and the Document Service
publish()method - Building calendar and Kanban views in TanStack Start with optimistic updates from TanStack Query
Prerequisites
This tutorial uses pinned versions. Mismatched versions are the most common reason code samples break, so match these:
| Dependency | Version |
|---|---|
| Node.js LTS | v24 LTS (or current LTS per nodejs.org) |
| Strapi | 5.x (latest stable) |
| TanStack Start | latest RC |
| TanStack Router | ships with Start |
| TanStack Query | v5.x |
| Tailwind CSS | v4.x |
| date-fns docs | latest stable |
| PostgreSQL database | 16.x |
You should be comfortable with TypeScript, React, and REST APIs. Familiarity with Strapi's admin panel helps but isn't required.
Setting Up the Strapi Backend
Step 1: Install Strapi 5
Scaffold a new project using the interactive installer:
npx create-strapi@latest editorial-backendThe installer walks through TypeScript (the default), database selection, and a Strapi Cloud login you can skip. Choose PostgreSQL and supply your connection details. Strapi 5 supports PostgreSQL 14 through 17, so version 16 sits comfortably in range.
If you prefer non-interactive setup with defaults (TypeScript, SQLite), pass --non-interactive with a directory argument:
npx create-strapi@latest editorial-backend --non-interactiveFor PostgreSQL in non-interactive mode, add the --dbclient flag and connection parameters:
npx create-strapi@latest editorial-backend --non-interactive \
--dbclient postgres \
--dbhost localhost \
--dbport 5432 \
--dbname editorial \
--dbusername your_user \
--dbpassword your_passwordOnce installed, generate TypeScript types so your custom code gets autocompletion:
npm run strapi ts:generate-types -- --debugTo regenerate types on every restart, add config/typescript.ts:
// config/typescript.ts
export default () => ({
autogenerate: true,
});Step 2: Define the ContentPiece, Author, and Channel Content-Types
You'll model three Collection Types. The cleanest path is the Content-Type Builder in the admin panel, which generates the schema.json files for you. The Blocks rich-text field can be added through the UI, where it shows up as "Rich text (blocks)" in the builder, or programmatically by editing your content-type schema.
Start the admin panel:
npm run developCreate the Channel Collection Type first, since the others reference it. It needs a name, a slug, and a color. The generated schema lands at src/api/channel/content-types/channel/schema.json:
{
"kind": "collectionType",
"collectionName": "channels",
"info": {
"singularName": "channel",
"pluralName": "channels",
"displayName": "Channel"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "name"
},
"color": {
"type": "string",
"default": "#3b82f6"
}
}
}Next, the Author Collection Type links a Strapi user to a display name and avatar. Its schema lives at src/api/author/content-types/author/schema.json:
{
"kind": "collectionType",
"collectionName": "authors",
"info": {
"singularName": "author",
"pluralName": "authors",
"displayName": "Author"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"displayName": {
"type": "string",
"required": true
},
"avatar": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"user": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"contentPieces": {
"type": "relation",
"relation": "oneToMany",
"target": "api::content-piece.content-piece",
"mappedBy": "author"
}
}
}The centerpiece is ContentPiece. Keep draftAndPublish enabled because the editorial workflow maps onto Strapi's native Draft & Publish states. Add the Blocks field for body through the builder ("Rich Text (Blocks)"), then the rest of the fields. The schema at src/api/content-piece/content-types/content-piece/schema.json looks like this:
{
"kind": "collectionType",
"collectionName": "content_pieces",
"info": {
"singularName": "content-piece",
"pluralName": "content-pieces",
"displayName": "ContentPiece"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"minLength": 3,
"maxLength": 255,
"required": true
},
"body": {
"type": "blocks"
},
"status": {
"type": "enumeration",
"enum": ["idea", "draft", "in_review", "approved", "scheduled", "published"],
"default": "idea",
"required": true
},
"scheduledAt": {
"type": "datetime"
},
"notes": {
"type": "text"
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "contentPieces"
},
"reviewer": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"channel": {
"type": "relation",
"relation": "manyToOne",
"target": "api::channel.channel"
}
}
}A note on the two status systems at play. The custom status enumeration tracks the editorial pipeline with six stages (idea, draft, in_review, approved, scheduled, published). Strapi's built-in Draft & Publish has only two API states, draft and published. The enumeration drives the team workflow; Strapi's publish() action fires only when a piece reaches the final stage.
The Document Service reads and writes the custom enumeration, while publish() toggles the native state. Keeping these separate is deliberate, since it lets the editorial pipeline be richer than the underlying publish mechanism.
Step 3: Configure RBAC for Three Editorial Roles
Strapi has two role systems. Admin Panel RBAC governs who can use the admin UI; the Users & Permissions plugin governs API access for your application's end users. Since the calendar app authenticates content team members through the API, you configure roles under Settings > Users & Permissions plugin > Roles.
Create three custom roles:
- Editor-in-Chief: full access, can publish content, manage permissions
- Writer: can create and edit drafts
- Reviewer: can read and update drafts. Workflow actions like approval or review require additional configuration via Review Workflows or custom development.
In the admin UI, click Add new role, fill in the Name and Description, then tick the permission boxes under the ContentPiece, Author, and Channel categories. Writers need find, findOne, create, and update. Reviewers need read and update access. Editor-in-Chief gets everything including the custom transition endpoint you'll build shortly.
You can also create roles through the REST API:
curl -X POST http://localhost:1337/api/users-permissions/roles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{"name": "Writer", "description": "Can create and edit own content", "type": "writer"}'The type value (writer, reviewer, editor_in_chief) matters because your transition logic reads it from ctx.state.user.role.type. Note the /api/users-permissions/ prefix, which is distinct from the standard /api/ content routes.
Step 4: Build Editorial Status Transition Middleware
This is where the pipeline gets enforced. In Strapi 5, lifecycle hooks are no longer the recommended approach. After the update to Strapi v5, lifecycle hooks are no longer the recommended approach for most use cases.
Instead, Strapi's documentation presents Document Service middleware as a high-level approach for most use cases that previously relied on lifecycle hooks, focusing on project events rather than database activity under the hood.
There's a concrete reason beyond preference. When creating a new document with status: 'published' on a content-type that has Draft & Publish enabled, Strapi fires afterCreate and beforeCreate twice—once for the draft, and once for the published version—because it creates both versions. The published record is not immutable and can be replaced by subsequent publishes. Document Service middlewares avoid that whole class of double-fire bugs.
You register middlewares in the register() phase. Register the middleware in src/index.ts (or edit the generated one):
// src/index.ts
import type { Core } from '@strapi/strapi';
const CONTENT_PIECE_UID = 'api::content-piece.content-piece';
const allowedTransitions: Record<string, string[]> = {
idea: ['draft'],
draft: ['in_review'],
in_review: ['approved', 'draft'],
approved: ['scheduled', 'in_review'],
scheduled: ['published'],
};
export default {
register({ strapi }: { strapi: Core.Strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== CONTENT_PIECE_UID) {
return next();
}
if (context.action !== 'update') {
return next();
}
const nextStatus = context.params?.data?.status;
if (!nextStatus) {
return next();
}
const { documentId } = context.params;
const existing = await strapi
.documents(CONTENT_PIECE_UID)
.findOne({ documentId, status: 'draft' });
if (!existing) {
return next();
}
const currentStatus = existing.status;
if (currentStatus === nextStatus) {
return next();
}
const permitted = allowedTransitions[currentStatus] ?? [];
if (!permitted.includes(nextStatus)) {
throw new Error(
`Invalid transition: cannot move from "${currentStatus}" to "${nextStatus}".`
);
}
return next();
});
},
bootstrap() {},
};Returning next() is mandatory. Omitting it breaks the request chain entirely. The middleware reads the current status from the draft version, checks the transition map, and throws if someone tries to skip a stage (idea straight to published, say).
Role-level enforcement belongs in a custom controller, since the middleware operates below the permissions layer and doesn't have clean access to the requesting user. Add a transitionStatus action at src/api/content-piece/controllers/content-piece.ts:
// src/api/content-piece/controllers/content-piece.ts
import { factories } from '@strapi/strapi';
import type { Core } from '@strapi/strapi';
const roleTransitions: Record<string, string[]> = {
writer: ['idea', 'draft', 'in_review'],
reviewer: ['approved', 'draft'],
editor_in_chief: ['scheduled', 'published', 'in_review', 'draft'],
};
export default factories.createCoreController(
'api::content-piece.content-piece',
({ strapi }: { strapi: Core.Strapi }) => ({
async transitionStatus(ctx) {
const { documentId } = ctx.params;
const { targetStatus } = ctx.request.body;
const user = ctx.state.user;
const userRole = user?.role?.type;
if (!userRole) {
return ctx.unauthorized('Authentication required');
}
const allowedForRole = roleTransitions[userRole] ?? [];
if (!allowedForRole.includes(targetStatus)) {
return ctx.forbidden(
`Your role cannot move content to "${targetStatus}"`
);
}
const piece = await strapi
.documents('api::content-piece.content-piece')
.findOne({ documentId, status: 'draft' });
if (!piece) {
return ctx.notFound('Content piece not found');
}
const updated = await strapi
.documents('api::content-piece.content-piece')
.update({
documentId,
data: { status: targetStatus },
});
if (targetStatus === 'published') {
await strapi
.documents('api::content-piece.content-piece')
.publish({ documentId });
}
ctx.body = updated;
},
})
);Wire it to a route at src/api/content-piece/routes/custom-content-piece.ts:
// src/api/content-piece/routes/custom-content-piece.ts
export default {
routes: [
{
method: 'PATCH',
path: '/content-pieces/:documentId/transition',
handler: 'api::content-piece.content-piece.transitionStatus',
config: {
policies: [],
middlewares: [],
},
},
],
};The two layers work together. The controller checks whether the user's role permits the target status, the document middleware checks whether the transition itself is valid regardless of who requests it. A Writer can move idea → draft → in review but never to approved. A Reviewer moves in review → approved or sends it back to draft. The Editor-in-Chief schedules and publishes.
Step 5: Build the Scheduled Publishing Cron Task
The cron task is what makes "scheduled" content actually publish on time. First, enable cron in config/server.ts:
// config/server.ts
import type { Core } from '@strapi/strapi';
import cronTasks from './cron-tasks';
export default ({ env }: { env: Core.ConfigProvider }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
cron: {
enabled: env.bool('CRON_ENABLED', true),
tasks: cronTasks,
},
});Cron is disabled by default, so the enabled flag matters. The next file to add is config/cron-tasks.ts:
// config/cron-tasks.ts
import type { Core } from '@strapi/strapi';
export default {
publishScheduledContent: {
task: async ({ strapi }: { strapi: Core.Strapi }) => {
const now = new Date().toISOString();
const pieces = await strapi
.documents('api::content-piece.content-piece')
.findMany({
filters: {
status: { $eq: 'approved' },
scheduledAt: { $lte: now },
},
status: 'draft',
});
for (const piece of pieces) {
await strapi
.documents('api::content-piece.content-piece')
.update({
documentId: piece.documentId,
data: { status: 'published' },
});
await strapi.documents('api::content-piece.content-piece').publish({
documentId: piece.documentId,
});
strapi.log.info(`Published content piece: ${piece.documentId}`);
}
},
options: {
rule: '0 * * * *',
},
},
};A few details worth flagging. The rule 0 * * * * runs at the top of every hour. Use the named-object format (publishScheduledContent) rather than the cron-expression-as-key format, since anonymous jobs cause trouble when disabling them or working with plugins.
The query passes status: 'draft' explicitly because Document Service find* methods default to draft, and your scheduled pieces exist as drafts until this task publishes them. The task updates the custom status enumeration to published, then calls publish() to transition Strapi's native draft/published state.
Building the TanStack Start Frontend
Step 1: Set Up the TanStack Start Project
Scaffold the frontend with the TanStack CLI:
npx @tanstack/cli@latest create editorial-frontendUse TanStack Start (which is a full‑stack SSR framework by default) and follow the standard CLI prompts; you’ll then be able to use server functions to talk to Strapi without selecting a separate “full-stack” or “SSR” option.
Add Tailwind v4 and date-fns:
cd editorial-frontend
npm install tailwindcss @tailwindcss/vite date-fnsConfigure vite.config.ts with the Tailwind and TanStack Start plugins:
// vite.config.ts
import { defineConfig } from 'vite';
import tsConfigPaths from 'vite-tsconfig-paths';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import tailwindcss from '@tailwindcss/vite';
import viteReact from '@vitejs/plugin-react';
export default defineConfig({
server: { port: 3000 },
plugins: [tsConfigPaths(), tanstackStart(), viteReact(), tailwindcss()],
});Tailwind v4 configures itself in CSS, so there's no tailwind.config.js. Add the import to src/styles.css:
/* src/styles.css */
@import "tailwindcss";Set your Strapi connection in .env:
# .env
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-hereGenerate an API token in the Strapi admin under Settings > API Tokens. Keep it server-side. The next step shows how server functions keep it out of the client bundle.
Create the server function that talks to Strapi's REST API at src/server/strapi.ts:
// src/server/strapi.ts
import { createServerFn } from '@tanstack/react-start';
export interface ContentPiece {
documentId: string;
title: string;
status: string;
scheduledAt: string | null;
author: { displayName: string } | null;
channel: { name: string; color: string; slug: string } | null;
}
export const fetchContentPieces = createServerFn().handler(async () => {
const token = process.env.STRAPI_API_TOKEN;
const url =
`${process.env.STRAPI_URL}/api/content-pieces` +
'?populate[0]=author' +
'&populate[1]=channel' +
'&status=draft' +
'&sort=scheduledAt:asc' +
'&pagination[pageSize]=25';
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Strapi error: ${res.status}`);
}
const json = await res.json();
return json.data as ContentPiece[];
});The token is read inside the handler, which runs only on the server. Strapi 5's flat response format means attributes sit directly on each object, so there's no data.attributes wrapper to unwrap. Always use documentId for follow-up calls, never the numeric id. Note the explicit populate[0]=author&populate[1]=channel, since relations are off by default in v5.
Step 2: Build the Calendar View
The calendar is a month grid built with date-fns. Each content piece lands on its scheduledAt date, color-coded by channel. The calendar route lives at src/routes/_authenticated/calendar.tsx:
// src/routes/_authenticated/calendar.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isSameMonth,
parseISO,
} from 'date-fns';
import { fetchContentPieces, type ContentPiece } from '../../server/strapi';
export const Route = createFileRoute('/_authenticated/calendar')({
component: CalendarPage,
});
function CalendarPage() {
const { data: pieces = [] } = useQuery({
queryKey: ['content-pieces'],
queryFn: () => fetchContentPieces(),
});
const today = new Date();
const monthStart = startOfMonth(today);
const monthEnd = endOfMonth(today);
const gridStart = startOfWeek(monthStart);
const gridEnd = endOfWeek(monthEnd);
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
const piecesForDay = (day: Date) =>
pieces.filter(
(p) => p.scheduledAt && isSameDay(parseISO(p.scheduledAt), day)
);
return (
<div className="p-6">
<h1 className="mb-4 text-2xl font-semibold">
{format(today, 'MMMM yyyy')}
</h1>
<div className="grid grid-cols-7 gap-px bg-gray-200">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="bg-gray-50 p-2 text-sm font-medium">
{d}
</div>
))}
{days.map((day) => (
<div
key={day.toISOString()}
className={`min-h-28 bg-white p-2 ${
isSameMonth(day, today) ? '' : 'text-gray-400'
}`}
>
<div className="text-sm">{format(day, 'd')}</div>
<div className="mt-1 space-y-1">
{piecesForDay(day).map((piece) => (
<CalendarItem key={piece.documentId} piece={piece} />
))}
</div>
</div>
))}
</div>
</div>
);
}
function CalendarItem({ piece }: { piece: ContentPiece }) {
return (
<div
className="truncate rounded px-2 py-1 text-xs text-white"
style={{ backgroundColor: piece.channel?.color ?? '#64748b' }}
title={piece.title}
>
{piece.title}
</div>
);
}The grid runs from the start of the week containing the first of the month to the end of the week containing the last day, which gives you the familiar full-rectangle calendar with trailing and leading days from adjacent months. eachDayOfInterval generates the day array, and parseISO turns Strapi's ISO date strings into comparable Date objects.
The monthly grid gives the team the big picture, but during a busy publishing week a tighter view helps. Add a weekly mode that shows a single seven-day strip with more vertical room per day so longer titles and multiple pieces stay readable.
// src/routes/_authenticated/calendar.tsx
import { useState } from 'react';
import {
startOfWeek,
endOfWeek,
eachDayOfInterval,
addWeeks,
format,
isSameDay,
parseISO,
} from 'date-fns';
import type { ContentPiece } from '../../server/strapi';
function WeekView({ pieces }: { pieces: ContentPiece[] }) {
const [anchor, setAnchor] = useState(new Date());
const weekStart = startOfWeek(anchor);
const weekEnd = endOfWeek(anchor);
const days = eachDayOfInterval({ start: weekStart, end: weekEnd });
const piecesForDay = (day: Date) =>
pieces.filter(
(p) => p.scheduledAt && isSameDay(parseISO(p.scheduledAt), day)
);
return (
<div className="p-6">
<div className="mb-4 flex items-center gap-3">
<button
className="rounded border px-3 py-1 text-sm"
onClick={() => setAnchor((d) => addWeeks(d, -1))}
>
Previous
</button>
<h2 className="text-lg font-semibold">
{format(weekStart, 'MMM d')} - {format(weekEnd, 'MMM d, yyyy')}
</h2>
<button
className="rounded border px-3 py-1 text-sm"
onClick={() => setAnchor((d) => addWeeks(d, 1))}
>
Next
</button>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200">
{days.map((day) => (
<div key={day.toISOString()} className="min-h-48 bg-white p-2">
<div className="text-sm font-medium">{format(day, 'EEE d')}</div>
<div className="mt-2 space-y-1">
{piecesForDay(day).map((piece) => (
<CalendarItem key={piece.documentId} piece={piece} />
))}
</div>
</div>
))}
</div>
</div>
);
}
function CalendarItem({ piece }: { piece: ContentPiece }) {
return (
<div
className="truncate rounded px-2 py-1 text-xs text-white"
style={{ backgroundColor: piece.channel?.color ?? '#64748b' }}
title={piece.title}
>
{piece.title}
</div>
);
}Wire a view state variable in CalendarPage and render either the month grid or WeekView based on it, with a pair of buttons that flip between 'month' and 'week'. Both views read from the same useQuery(['content-pieces']) cache, but under default React Query settings stale cached data can still trigger a background refetch when components remount or become active again. The addWeeks helper from date-fns docs advances the anchor date by a positive or negative offset, which drives the Previous and Next navigation.
Step 3: Build the Pipeline Board
The Kanban board groups content by status with drag-to-advance interaction. Dragging a card to the next column calls the transition endpoint, and TanStack Query applies an optimistic update so the move feels instant. Define the board route at src/routes/_authenticated/board.tsx:
// src/routes/_authenticated/board.tsx
import { createFileRoute } from '@tanstack/react-router';
import {
useQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { fetchContentPieces, type ContentPiece } from '../../server/strapi';
import { transitionPiece } from '../../server/transitions';
export const Route = createFileRoute('/_authenticated/board')({
component: BoardPage,
});
const COLUMNS = [
'idea',
'draft',
'in_review',
'approved',
'scheduled',
'published',
] as const;
function BoardPage() {
const queryClient = useQueryClient();
const { data: pieces = [] } = useQuery({
queryKey: ['content-pieces'],
queryFn: () => fetchContentPieces(),
});
const moveToStatus = useMutation({
mutationKey: ['moveToStatus'],
mutationFn: ({
documentId,
status,
}: {
documentId: string;
status: string;
}) => transitionPiece({ data: { documentId, status } }),
onMutate: async ({ documentId, status }) => {
await queryClient.cancelQueries({ queryKey: ['content-pieces'] });
const previousPieces = queryClient.getQueryData<ContentPiece[]>([
'content-pieces',
]);
queryClient.setQueryData<ContentPiece[]>(['content-pieces'], (old) =>
(old ?? []).map((p) =>
p.documentId === documentId ? { ...p, status } : p
)
);
return { previousPieces };
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(['content-pieces'], context?.previousPieces);
},
onSettled: () => {
return queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
},
});
return (
<div className="flex gap-4 overflow-x-auto p-6">
{COLUMNS.map((status) => (
<div
key={status}
className="w-64 shrink-0 rounded bg-gray-100 p-3"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const documentId = e.dataTransfer.getData('text/plain');
moveToStatus.mutate({ documentId, status });
}}
>
<h2 className="mb-2 text-sm font-semibold capitalize">
{status.replace('_', ' ')}
</h2>
<div className="space-y-2">
{pieces
.filter((p) => p.status === status)
.map((piece) => (
<div
key={piece.documentId}
draggable
onDragStart={(e) =>
e.dataTransfer.setData('text/plain', piece.documentId)
}
className="cursor-grab rounded bg-white p-2 text-sm shadow-sm"
>
{piece.title}
</div>
))}
</div>
</div>
))}
</div>
);
}The optimistic update pattern follows the cache-based approach. onMutate cancels in-flight refetches, snapshots the current cache, and writes the new status immediately. If the request fails (say a Writer tries to publish something), onError rolls back to the snapshot. onSettled always refetches to reconcile with server truth, and returning the invalidateQueries promise keeps the mutation in its pending state until the refetch finishes.
The transition server function at src/server/transitions.ts calls the custom Strapi endpoint:
// src/server/transitions.ts
import { createServerFn } from '@tanstack/react-start';
export const transitionPiece = createServerFn()
.validator((input: { documentId: string; status: string }) => input)
.handler(async ({ data }) => {
const token = process.env.STRAPI_API_TOKEN;
const res = await fetch(
`${process.env.STRAPI_URL}/api/content-pieces/${data.documentId}/transition`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ targetStatus: data.status }),
}
);
if (!res.ok) {
throw new Error(`Transition failed: ${res.status}`);
}
return res.json();
});Using .validator() on server functions that accept input is a baseline security practice, since server functions are reachable by direct POST regardless of which route renders them.
Step 4: Build Role-Specific Views
Route guards live in beforeLoad. Set up router context to carry the authenticated user in src/routes/__root.tsx:
// src/routes/__root.tsx
import {
createRootRouteWithContext,
Outlet,
} from '@tanstack/react-router';
interface RouterContext {
auth: {
isAuthenticated: boolean;
user: { role: string; id: string } | null;
};
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
});The pathless _authenticated layout redirects unauthenticated users. Create src/routes/_authenticated.tsx:
// src/routes/_authenticated.tsx
import {
createFileRoute,
redirect,
Outlet,
} from '@tanstack/react-router';
import { getCurrentUserFn } from '../server/auth';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
const user = await getCurrentUserFn();
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
});
}
return { user };
},
component: () => <Outlet />,
});For role-based filtering, define a small helper at src/utils/auth.ts:
// src/utils/auth.ts
export const roles = {
WRITER: 'writer',
REVIEWER: 'reviewer',
EDITOR: 'editor',
EDITOR_IN_CHIEF: 'editor_in_chief',
} as const;
type Role = (typeof roles)[keyof typeof roles];
const hierarchy: Record<Role, number> = {
writer: 0,
reviewer: 1,
editor: 2,
editor_in_chief: 3,
};
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
return hierarchy[userRole] >= hierarchy[requiredRole];
}Guard the full calendar so only the Editor-in-Chief sees it. The beforeLoad here reads the user merged into context by the parent _authenticated route:
// src/routes/_authenticated/admin/index.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { hasPermission, roles } from '../../utils/auth';
export const Route = createFileRoute('/_authenticated/admin/')({
beforeLoad: async ({ context }) => {
if (!hasPermission(context.user.role, roles.EDITOR_IN_CHIEF)) {
throw redirect({ to: '/unauthorized' });
}
},
});One critical caveat: a route guard handles UX, not authorization. As the TanStack docs warn, "a route guard does not protect a server function. The route guard does NOT stop a direct request to that RPC." Real enforcement lives in the Strapi controller's role check and the document middleware. The frontend guard exists so a Writer doesn't see buttons they can't use, not as the security boundary itself.
For the Writer view, filter fetchContentPieces to their assigned items and show a "submit for review" action. A second server function targets a specific status at the Strapi query level:
// src/server/by-status.ts
import { createServerFn } from '@tanstack/react-start';
import type { ContentPiece } from './strapi';
export const fetchPiecesByStatus = createServerFn()
.validator((input: { status: string }) => input)
.handler(async ({ data }) => {
const token = process.env.STRAPI_API_TOKEN;
const url =
`${process.env.STRAPI_URL}/api/content-pieces` +
'?populate[0]=author' +
'&populate[1]=channel' +
'&status=draft' +
`&filters[status][$eq]=${data.status}` +
'&sort=scheduledAt:asc';
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Strapi error: ${res.status}`);
}
const json = await res.json();
return json.data as ContentPiece[];
});The Reviewer view calls fetchPiecesByStatus({ data: { status: 'in_review' } }) and renders the queue awaiting review, with Approve and Send back buttons that fire the transition endpoint. The Writer view passes status: 'draft' and adds an author filter so writers see only their own pieces.
Filtering at the Strapi query level keeps payloads small, since the server returns only the rows that view needs rather than the full board. Note the &status=draft parameter remains needed for REST API queries to fetch draft entries, while Document Service API queries default to draft entries if status is omitted; the filters[status][$eq] parameter targets the custom editorial enumeration.
Putting It All Together
Time to walk the pipeline end to end. Log in as a Writer and create a content piece. It starts in idea, and the Writer advances it idea → draft, fills in the body, then submits for review (draft → in review). The document middleware permits each step; the controller confirms the Writer's role allows it. Because the controller and middleware enforce the same transition map from opposite angles, a malformed request that slips past the UI still gets rejected at the service layer.
Log in as a Reviewer. The in-review queue shows the piece. Approve it (in review → approved), or send it back to draft if it needs work. Try jumping straight from in review to scheduled and the middleware rejects it, since scheduled isn't in the allowed transitions from in_review. The error surfaces back through the server function as a failed response, which triggers the optimistic rollback on the board.
Log in as the Editor-in-Chief. Drag the approved piece to scheduled on the board and set a scheduledAt date a few minutes in the future for testing. To watch the cron task fire quickly, temporarily change the rule to run every minute (* * * * *) instead of hourly. Within a minute of the scheduled time passing, the task finds the piece, updates its status to published, and calls publish() on the Document Service.
Check the Strapi admin: the piece now shows as Published, and publishedAt carries a timestamp. The piece also becomes visible through the public REST API at this point, since published documents are what the default status returns. Revert the cron rule to 0 * * * * for production.
Next Steps
You have a working editorial calendar, but there's room to grow. A few directions worth pursuing:
- Deploy to Strapi Cloud for managed hosting that handles the database and scaling. See Strapi's deployment documentation for the workflow.
- Add Slack notifications when content moves to "in review," so Reviewers don't have to poll the board. Strapi webhooks can push those events to an external service.
- Integrate a headless frontend that consumes the published content through Strapi's REST API. Browse the Strapi integrations for framework starters.
- Track publishing velocity by aggregating transition timestamps into a simple analytics view.
- Add content templates so Writers start new pieces from a structured outline rather than a blank Blocks editor.
For deeper dives, the Strapi documentation covers the Document Service API, RBAC, and cron configuration in detail, and the TanStack Start docs cover routing, server functions, and the execution model.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.