Content scheduling lets you prepare posts in advance and publish them automatically. Here is how to build a complete scheduling system.
Database Schema
// db/schema.ts
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
export const contentStatusEnum = pgEnum("content_status", [
"draft",
"scheduled",
"published",
"archived",
]);
export const posts = pgTable("posts", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content").notNull().default(""),
excerpt: text("excerpt"),
status: contentStatusEnum("status").notNull().default("draft"),
scheduledAt: timestamp("scheduled_at"),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
authorId: text("author_id").notNull(),
});
Content Service
// lib/content-service.ts
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, and, lte, desc } from "drizzle-orm";
export async function createPost(data: {
title: string;
slug: string;
content: string;
excerpt?: string;
authorId: string;
}) {
const [post] = await db.insert(posts).values(data).returning();
return post;
}
export async function updatePost(
id: string,
data: Partial<{
title: string;
slug: string;
content: string;
excerpt: string;
status: "draft" | "scheduled" | "published" | "archived";
scheduledAt: Date | null;
}>
) {
const [post] = await db
.update(posts)
.set({ ...data, updatedAt: new Date() })
.where(eq(posts.id, id))
.returning();
return post;
}
export async function schedulePost(id: string, scheduledAt: Date) {
if (scheduledAt <= new Date()) {
throw new Error("Scheduled date must be in the future");
}
return updatePost(id, {
status: "scheduled",
scheduledAt,
});
}
export async function publishPost(id: string) {
return updatePost(id, {
status: "published",
scheduledAt: null,
});
}
export async function publishScheduledPosts() {
const now = new Date();
const scheduled = await db
.select()
.from(posts)
.where(
and(
eq(posts.status, "scheduled"),
lte(posts.scheduledAt, now)
)
);
const published: typeof scheduled = [];
for (const post of scheduled) {
const [updated] = await db
.update(posts)
.set({
status: "published",
publishedAt: now,
updatedAt: now,
})
.where(eq(posts.id, post.id))
.returning();
if (updated) published.push(updated);
}
return published;
}
export async function getPublishedPosts() {
return db
.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
}
export async function getPostsByStatus(status: "draft" | "scheduled" | "published" | "archived") {
return db
.select()
.from(posts)
.where(eq(posts.status, status))
.orderBy(desc(posts.updatedAt));
}
Cron Job for Auto-Publishing
Using Vercel Cron:
// app/api/cron/publish/route.ts
import { NextRequest, NextResponse } from "next/server";
import { publishScheduledPosts } from "@/lib/content-service";
export async function GET(request: NextRequest) {
// Verify the request is from Vercel Cron
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const published = await publishScheduledPosts();
return NextResponse.json({
publishedCount: published.length,
posts: published.map((p) => ({ id: p.id, title: p.title, slug: p.slug })),
});
}
Configure in vercel.json:
{
"crons": [
{
"path": "/api/cron/publish",
"schedule": "*/5 * * * *"
}
]
}
Content Editor Page
// app/(site)/admin/posts/[id]/page.tsx
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { PostEditor } from "@/components/admin/PostEditor";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditPostPage({ params }: Props) {
const { id } = await params;
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
});
if (!post) notFound();
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<PostEditor post={post} />
</div>
);
}
Post Editor Component
"use client";
import { useState, useTransition } from "react";
interface Post {
id: string;
title: string;
slug: string;
content: string;
excerpt: string | null;
status: "draft" | "scheduled" | "published" | "archived";
scheduledAt: Date | null;
}
export function PostEditor({ post }: { post: Post }) {
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [excerpt, setExcerpt] = useState(post.excerpt ?? "");
const [scheduleDate, setScheduleDate] = useState(
post.scheduledAt ? post.scheduledAt.toISOString().slice(0, 16) : ""
);
const [saving, startSave] = useTransition();
async function save(action: "save" | "schedule" | "publish") {
const body: Record<string, unknown> = { title, content, excerpt };
if (action === "schedule" && scheduleDate) {
body.status = "scheduled";
body.scheduledAt = new Date(scheduleDate).toISOString();
} else if (action === "publish") {
body.status = "published";
}
startSave(async () => {
await fetch(`/api/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
});
}
return (
<div className="space-y-6">
{/* Status Badge */}
<div className="flex items-center justify-between">
<StatusBadge status={post.status} />
{post.scheduledAt && post.status === "scheduled" && (
<span className="text-sm text-muted-foreground">
Scheduled for {post.scheduledAt.toLocaleString()}
</span>
)}
</div>
{/* Title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full text-3xl font-bold border-none outline-none bg-transparent"
placeholder="Post title..."
/>
{/* Excerpt */}
<textarea
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-sm resize-none"
rows={2}
placeholder="Brief excerpt..."
/>
{/* Content */}
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-3 py-2 border rounded-md min-h-[400px] font-mono text-sm"
placeholder="Write your content..."
/>
{/* Schedule Date */}
{post.status !== "published" && (
<div>
<label className="block text-sm font-medium mb-1">Schedule for</label>
<input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="px-3 py-2 border rounded-md text-sm"
/>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-4 border-t">
<button
onClick={() => save("save")}
disabled={saving}
className="px-4 py-2 border rounded-md text-sm hover:bg-muted"
>
{saving ? "Saving..." : "Save Draft"}
</button>
{scheduleDate && post.status !== "published" && (
<button
onClick={() => save("schedule")}
disabled={saving}
className="px-4 py-2 bg-yellow-500 text-white rounded-md text-sm hover:bg-yellow-600"
>
Schedule
</button>
)}
<button
onClick={() => save("publish")}
disabled={saving}
className="px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 ml-auto"
>
Publish Now
</button>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
draft: "bg-gray-100 text-gray-700",
scheduled: "bg-yellow-100 text-yellow-700",
published: "bg-green-100 text-green-700",
archived: "bg-red-100 text-red-700",
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status] ?? ""}`}>
{status}
</span>
);
}
Posts List with Status Tabs
// components/admin/PostsList.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
type Status = "draft" | "scheduled" | "published" | "archived";
interface Post {
id: string;
title: string;
status: Status;
scheduledAt: string | null;
publishedAt: string | null;
updatedAt: string;
}
const tabs: { label: string; value: Status }[] = [
{ label: "Drafts", value: "draft" },
{ label: "Scheduled", value: "scheduled" },
{ label: "Published", value: "published" },
{ label: "Archived", value: "archived" },
];
export function PostsList({ initialPosts }: { initialPosts: Post[] }) {
const [activeTab, setActiveTab] = useState<Status>("draft");
const filtered = initialPosts.filter((p) => p.status === activeTab);
return (
<div>
<div className="flex gap-1 border-b mb-6">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`px-4 py-2 text-sm -mb-px ${
activeTab === tab.value
? "border-b-2 border-primary font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label} ({initialPosts.filter((p) => p.status === tab.value).length})
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No {activeTab} posts
</p>
) : (
<ul className="space-y-2">
{filtered.map((post) => (
<li key={post.id}>
<Link
href={`/admin/posts/${post.id}`}
className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/50"
>
<span className="font-medium">{post.title}</span>
<span className="text-xs text-muted-foreground">
{post.scheduledAt
? `Scheduled: ${new Date(post.scheduledAt).toLocaleDateString()}`
: post.publishedAt
? `Published: ${new Date(post.publishedAt).toLocaleDateString()}`
: `Updated: ${new Date(post.updatedAt).toLocaleDateString()}`}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
Need a Custom CMS?
We build content management systems with scheduling, workflows, and multi-user editing. Contact us to create your custom CMS.