Publishing without a redeploy: drafts, scheduling and ISR
This is part 4 of a series on how this blog is built. Part 3 covered how a post is stored and rendered. This part is about the moment a post goes live, which turned out to be the most intricate logic in the project.
I wanted three things that pull against each other:
Public pages should be static and fast.
A new post should appear the moment I press Publish, with no build and no deploy.
I should be able to edit a published post over several days without readers seeing half-finished changes.
Cached pages, refreshed on demand
Every public page uses incremental static regeneration. The page is rendered on the first request, cached, and served from the cache after that. The database is not touched for most visits.
A cache is only useful if you can clear it at the right time. When I press Publish, the server action saves the post and then tells Next.js which pages are now out of date:
export function revalidatePostPages(opts: { slugs?: string[]; tagSlugs?: string[] } = {}) {
// Literal URLs first: these are the pages a reader is most likely on.
revalidatePath("/");
revalidatePath("/blog");
for (const slug of new Set(opts.slugs)) revalidatePath(`/blog/${slug}`);
for (const tag of new Set(opts.tagSlugs)) revalidatePath(`/tags/${tag}`);
// Every tag list: a post can gain or lose tags the caller did not pass.
revalidatePath("/(site)/tags/[tag]", "page");
revalidatePath("/sitemap.xml");
revalidatePath("/rss.xml");
}The next visitor to any of those pages gets a freshly rendered one. On Vercel this clears the cache at the edge everywhere, so there is nothing to redeploy.
The same function is available over HTTP, behind a secret, for the day I want to trigger it from a script:
curl -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $REVALIDATE_SECRET" \
-d '{"slugs":["my-post"]}'The route group that matched nothing
One line in that function is easy to get wrong. To refresh every topic page at once, you pass a route pattern to revalidatePath instead of a URL. The obvious way to write it is this:
revalidatePath("/tags/[tag]", "page");It does nothing, and it does not complain. The pattern is matched against the route's file path, not its URL. My topic pages live in app/(site)/tags/[tag], and the (site) folder, which never appears in a URL, is part of that path. The working version has to include it:
revalidatePath("/(site)/tags/[tag]", "page");Three states
A post is a draft, scheduled or published. The rule for what the public can see is a single condition, used by every public query:
// A post is public once it is published (or scheduled) and its time has come.
const isLive = and(
inArray(posts.status, ["published", "scheduled"]),
isNotNull(posts.publishedAt),
lte(posts.publishedAt, sql`now()`),
);Notice that scheduled is in that list. A scheduled post is not waiting for something to flip it to published. It becomes visible by itself the moment its time passes, because the query compares its date with the database clock.
[Screenshot to add: The editor header with the Save draft and Publish buttons, and the Details panel open]
[Screenshot to add: The posts table in the admin, showing draft, scheduled and published badges]
Scheduling without a cron job
The usual way to schedule a post is a job that runs every minute and publishes whatever is due. I did not want one. It is another moving part, and on a free plan it is another limit to think about.
Instead, two things work together.
First, as above, a scheduled post is already public once its time has come. Nothing has to run.
Second, the list pages have a short safety timer:
// ISR: cached, refreshed on demand when a post is published, and at most
// every minute otherwise (also makes scheduled posts appear on time).
export const revalidate = 60;Nothing clears the cache at the scheduled moment, because nothing is running then. The timer means the cached list is never more than a minute old, so a post scheduled for 9:00 shows up by about 9:01.
There is one loose end. The post is public but its status still says scheduled, which would be confusing in the admin. So whenever anyone reads posts, a small sweep tidies up first:
/**
* There is no cron: scheduled things are settled whenever somebody reads posts.
* At most once per `maxAgeMs`.
*/
export function publishDue(maxAgeMs = 30_000) {
if (sweeping) return sweeping;
if (Date.now() - lastSweep < maxAgeMs) return Promise.resolve({ slugs: [], tagSlugs: [] });
lastSweep = Date.now();
sweeping = (async () => {
const flipped = await db
.update(posts)
// Keep updatedAt: nothing about the post was edited.
.set({ status: "published", updatedAt: sql`${posts.updatedAt}` })
.where(and(eq(posts.status, "scheduled"), lte(posts.publishedAt, sql`now()`)))
.returning({ slug: posts.slug });
// ...
})().finally(() => {
sweeping = null;
});
return sweeping;
}It runs at most once every thirty seconds, and concurrent requests share one run instead of starting their own. Admin pages use a two second limit instead, so a status in the admin is never behind the clock.
[Screenshot to add: The schedule picker open, with the calendar and the time boxes]
Editing a post that is already live
Once a post is published, pressing "Save draft" does not touch the live version. The edits are stored next to it, in a second JSON column:
/** The editable fields of a post, as held in `posts.draft`. */
export type PostDraft = {
title: string;
slug: string;
excerpt: string;
content: JSONContent;
coverImage: string;
tags: string[];
seoTitle: string;
seoDescription: string;
/** ISO time at which the draft replaces the live version by itself. */
publishAt?: string | null;
};The save action checks for this case before anything else:
// A published post stays live while its edits wait: either until "Update", or,
// when scheduled, until the chosen time.
if (existing?.status === "published" && (input.asDraft || input.status === "scheduled")) {
await db.update(posts).set({ draft: { /* the edited fields */ } }).where(eq(posts.id, existing.id));
// Nothing public changed, so there is nothing to revalidate.
return { ok: true, id: existing.id, slug: existing.slug, status: "published", hasDraft: true, draftPublishAt: publishAt };
}Readers keep seeing the published version. I see my edits in the editor, with a note that a draft is waiting. Pressing "Update" copies the draft over the live fields and clears it. "Discard draft" throws the edits away and goes back to the live version.
A draft can also be scheduled, which gives scheduled updates: an edit to a live post that goes out by itself at a time I choose. The same sweep handles it, and this is where it has to be careful:
async function promoteDraft(id: string) {
return db.transaction(async (tx) => {
// Locked, and re-checked: another request may have promoted it a moment ago.
const [post] = await tx
.select()
.from(posts)
.where(and(eq(posts.id, id), isNotNull(posts.draft), draftIsDue))
.for("update");
const draft = post?.draft;
if (!post || !draft) return null;
// ...
});
}Two requests can arrive in the same second and both decide a draft is due. The row lock makes the second one wait, and the repeated condition means that when it gets the row, the draft is already gone and it does nothing.
There is a second small trap. A draft can change the post's address. The new address was free when I saved the draft, but another post might have taken it since. If so, the post keeps its old address rather than failing.
Search that never queries the database
The blog list has a search box and a topic filter. Neither one sends a request. The /blog page is one cached page holding a short summary of every post, and the filtering happens in the browser:
const searchable = posts.map((post) => ({
post,
text: [post.title, post.excerpt ?? "", ...post.tags.map((t) => t.name)].join(" ").toLowerCase(),
}));
const terms = deferredQuery.toLowerCase().split(/\s+/).filter(Boolean);
const results = searchable
.filter(
({ post, text }) =>
(!topic || post.tags.some((t) => t.slug === topic)) && terms.every((term) => text.includes(term)),
)
.map(({ post }) => post);For a personal blog this is the right size of solution. A few hundred summaries are a small download, the results update as fast as I can type, and the filters are mirrored into the URL as ?q= and ?topic= so a filtered view can be shared.
[Screenshot to add: The blog page with a search term typed and a topic selected]
Building with no database
The last problem was the build itself. CI runs next build on every push, and CI has no database. A page that queries Postgres while it is being prerendered would fail the build.
Every database read on a public page goes through a small wrapper:
export async function buildSafe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
try {
return await fn();
} catch (error) {
if (process.env.NEXT_PHASE === "phase-production-build") {
console.warn("[build] database unavailable, using empty data");
return fallback;
}
throw error;
}
}During the build, a failed query returns empty data. At runtime, errors are thrown as normal; I do not want a dead database to quietly show an empty blog. A page that was built empty fixes itself, because of the same short timers that make scheduling work.
Part 5 moves to the other side of the login page: how the admin is locked to one person, and how images get uploaded.
The whole series
Publishing without a redeploy: drafts, scheduling and ISR (this post)
A one-person admin: Google sign-in, a sign-in log and direct uploads