Publishing consistently is easier said than done when the alternative is remembering to push a button at the right time. For nixx.dev, the solution was to remove the manual step entirely: posts are written, scheduled via front matter, and published automatically when the scheduled time arrives.
The system uses three components: Markdown files with a
statusfield in the front matter, a GitHub Actions cron workflow that runs daily, and a Next.js Route Handler webhook that checks for due posts and updates their status topublished. There is no CMS dependency and no third-party publishing tool. Everything lives in the repository.This post walks through how each component works and how they fit together.
What this covers:
Storing post metadata and schedule in Markdown front matter
A lightweight admin interface for managing drafts (optional)
The GitHub Actions cron workflow
The Next.js Route Handler webhook logic
Rebuild and deployment on publish
Step 1: Store Post Metadata in Markdown Front Matter
All blog content on nixx.dev is stored in Markdown files in /content/blog. Each file includes a front matter block with the publish date and a status field:
---
title: "How to Build a Website in 2025"
date: "2025-04-10T08:00:00Z"
status: draft
---The status field accepts three values: draft, scheduled, and published. When a post is ready to go live at a future date, the status is set to scheduled and the date field is set to the target publish time in ISO 8601 format.
This structure keeps content management entirely within the repository. Posts can be written, reviewed, and committed to GitHub before they are live, and the visibility state is version-controlled alongside the content itself.
Step 2: Manage Posts with a Lightweight Admin Interface (Optional)
For teams or content-heavy projects, editing front matter directly in code is workable but friction-prone. We built a small login-protected dashboard that allows drafts to be edited visually, unpublished posts to be previewed, and scheduling to be set through a date picker that writes back to the front matter.
This step is optional. A solo developer comfortable with editing Markdown files directly can skip it entirely. The automation in Steps 3 and 4 works the same regardless of whether the status field was set through an interface or a text editor.
Step 3: Trigger a Daily Check with GitHub Actions
The automation runs via a GitHub Actions scheduled workflow that fires every day at 07:00 UTC. The workflow's only job is to call the webhook endpoint, which handles the actual publishing logic.
Create .github/workflows/publish-scheduled.yml:
name: Publish Scheduled Posts
on:
schedule:
- cron: '0 7 * * *' # Every day at 7:00 AM UTC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Trigger Webhook
uses: distributhor/workflow-webhook@v1
with:
url: https://nixx.dev/api/webhooks/check-scheduled-posts
method: POSTThe workflow is intentionally minimal. Keeping the scheduling logic in the application rather than in the GitHub Actions workflow means it is easier to test locally, easier to debug when something goes wrong, and not coupled to GitHub's infrastructure beyond the trigger.
Note: GitHub does not guarantee exact cron timing. Scheduled workflows may be delayed by a few minutes under high load. For a publishing system, this is acceptable. For anything requiring second-level precision, a different approach would be needed.
Step 4: Write the Webhook Logic with a Next.js Route Handler
With Next.js v15 and the App Router, the webhook is implemented as a Route Handler at app/api/webhooks/check-scheduled-posts/route.ts.
import { promises as fs } from 'fs';
import path from 'path';
import matter from 'gray-matter';
export async function POST() {
const POSTS_DIR = path.join(process.cwd(), 'content/blog');
const files = await fs.readdir(POSTS_DIR);
for (const file of files) {
const filePath = path.join(POSTS_DIR, file);
const content = await fs.readFile(filePath, 'utf-8');
const { data } = matter(content);
const now = new Date();
const publishDate = new Date(data.date);
if (data.status === 'scheduled' && publishDate <= now) {
const updated = matter.stringify(content, { ...data, status: 'published' });
await fs.writeFile(filePath, updated);
console.log(`Published: ${data.title}`);
}
}
return new Response(
JSON.stringify({ message: 'Checked and published scheduled posts.' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}The logic is straightforward:
Read all files in the
/content/blogdirectoryParse each file's front matter using
gray-matterFor any file with
status: scheduledand adatethat is now in the past, rewrite the file withstatus: publishedReturn a 200 response confirming the check completed
The gray-matter package handles both reading and writing front matter without disturbing the rest of the Markdown content. Install it with npm install gray-matter.
One consideration for production: this webhook writes directly to the filesystem, which works on a self-hosted or VPS deployment where the repository is checked out on the server. On Vercel or other serverless platforms, the filesystem is read-only at runtime. In that case, the webhook would need to commit the status change back to the repository via the GitHub API rather than writing the file directly, and the subsequent commit would trigger a new deployment.
Step 5: Rebuild and Deploy After Publishing
Writing the updated status: published to the Markdown file is the trigger, but the site needs to rebuild before the post is visible to visitors.
For staging on Vercel, deployment is automatic on every push to the connected branch. If the webhook commits the status change back to the repository, Vercel detects the commit and triggers a rebuild.
For production on a custom VPS, the deploy script runs after the webhook completes:
cd /var/www/nixx.dev
git pull origin production
npm run build
pm2 restart nextThis can be integrated into the webhook handler itself, triggered as a separate step in the GitHub Actions workflow after the webhook call, or run via a deployment hook in the hosting platform's settings.
Complete System Overview
Component | Role |
|---|---|
Markdown front matter | Stores post content, publish date, and status |
| Controls visibility: |
GitHub Actions cron | Triggers daily at 07:00 UTC |
Next.js Route Handler | Checks post dates and updates status to |
VPS deploy script | Rebuilds and restarts the site after publishing |
Key Takeaways
Storing
statusanddatein Markdown front matter keeps scheduling version-controlled alongside content and removes any dependency on a CMS or external publishing tool.A GitHub Actions cron workflow is the lightest way to trigger a daily check. The scheduling logic itself belongs in the application, not the workflow.
The Next.js Route Handler uses
gray-matterto read and write front matter without affecting the Markdown body content.On serverless platforms where the filesystem is read-only at runtime, the webhook needs to commit status changes back to the repository via the GitHub API instead of writing files directly.
Rebuilding and redeploying after the status change is the final step. On Vercel this is automatic if the change is committed. On a VPS it requires a deploy script triggered after the webhook.
Conclusion
The complete system is modest in complexity but effective in practice. Posts are written and committed ahead of time, and the publishing step happens without requiring anyone to be present. The entire workflow is version-controlled, CMS-free, and runs on infrastructure already in use for deployment.
For any Next.js project that manages content in Markdown and wants scheduled publishing without adding a CMS dependency, this approach is worth considering. The webhook logic is under fifty lines, the GitHub Actions workflow is a dozen, and the only external dependency beyond Next.js itself is gray-matter.
Managing content on a Next.js site with a different approach? Share how your setup works in the comments.




