Skip to main content
Back to Blog
Tutorials
4 min read
December 11, 2024

How to Build a Content Scheduling System in Next.js

Build a content scheduling system with draft/published states, scheduled publish dates, and automatic publishing via cron jobs in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

content schedulingCMScronpublishingNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles