Next.jsブログに予約投稿機能を実装する【frontmatterの日付だけで管理】
このブログはCMSを使わず、記事をMarkdownファイルとしてリポジトリ内に直接置く構成にしています。そのため「予約投稿」を実現するにも、CMSの管理画面のようなスケジュール機能はなく、自前で仕組みを用意する必要がありました。実際の実装はsrc/lib/posts.tsのisPublished関数に集約されていて、次の形になっています。
/**
* 予約投稿機能: frontmatterの`date`を未来の日付にしておくと、
* その日の日本時間21時(Xがよく見られる時間帯)になるまで
* 一覧・sitemap・記事ページのどこにも出さない。
* RSS経由の自動ツイート(dlvr.it等、post immediately設定)も、
* この時刻に合わせて動くようにするための調整。
*/
const PUBLISH_HOUR_JST = 21;
function getPublishInstant(dateStr: string): number {
const hour = String(PUBLISH_HOUR_JST).padStart(2, "0");
return new Date(`${dateStr}T${hour}:00:00+09:00`).getTime();
}
export function isPublished(data: PostFrontmatter): boolean {
if (data.draft) return false;
// ローカルで `npm run dev` しているときだけ、予約日付・時刻を無視してすべて表示する。
// 管理者が公開前の記事を実際の見た目で確認できるようにするための特例で、
// 本番ビルド(`next build`/`next start`、Vercel上での実行)には一切影響しない。
if (process.env.NODE_ENV === "development") return true;
return Date.now() >= getPublishInstant(data.date);
}💭 実装してみて思うこと
予約投稿と聞くと大掛かりな仕組みを想像しがちですが、実際にはfrontmatterの日付と現在時刻を比較するだけの、驚くほど短い関数で実現できてしまいました。実装していて時間がかかったのはロジックそのものより、「そもそも何時に公開するのが正しいのか」という運用面の判断でした。
仕組みの全体像
やっていることはシンプルで、draft: trueなら問答無用で非公開、そうでなければfrontmatterのdateが指す日本時間21時を過ぎているかどうかで、記事一覧・記事ページ・sitemap.xmlのどこに出すかを判定しています。日付だけを未来にしてpushしておけば、その日の21時になった瞬間に自動で公開に切り替わります。
なぜ「21時固定」にしたのか
この実装で一番語る価値があるのは、コードそのものより「なぜ日本時間21時なのか」という運用上の判断です。技術的には0時公開のほうが素直な実装になりますが、あえて21時に固定しました。理由は2つあります。
- X(旧Twitter)がよく見られる時間帯に合わせるため: 仕事や学校が終わった後の夜の時間帯は、SNSの閲覧が増えやすいタイミングです。深夜0時に公開しても、朝まで誰の目にも触れないまま埋もれてしまう可能性が高くなります。
- RSS経由の自動ツイートと連動させるため: このブログのRSSフィードはdlvr.it等の外部サービス経由でXへの自動投稿に使われています。dlvr.itのようなサービスは「post immediately」設定で新着フィードを検知次第すぐに投稿する仕組みが多いため、フィードに載るタイミング自体を狙った時間に合わせておく必要があります。公開時刻をコード側で21時に固定しておけば、記事ごとに個別のツイート予約をしなくても、狙った時間帯に自動でSNS告知まで完了します。
「いつコードを実行するか」ではなく「いつ読者の目に触れさせたいか」を起点に実装を決める、という順番になったのがこの機能の実装で一番印象に残っている点です。
UTC/JSTのズレをどう処理したか
もう1つのつまずきポイントは、Vercelなどのホスティング環境のサーバー時刻がUTC(協定世界時)で動いていることでした。日本時間(JST)はUTCより9時間進んでいるため、時刻の扱いを誤ると「日本時間の21時のつもりが、サーバー側では違う時刻として判定される」というズレが起きます。
このズレへの対処は、Date.now()に手を加えるような方法ではなく、Dateのコンストラクタにタイムゾーンを直接指定する方法で解決しています。
function getPublishInstant(dateStr: string): number {
const hour = String(PUBLISH_HOUR_JST).padStart(2, "0");
return new Date(`${dateStr}T${hour}:00:00+09:00`).getTime();
}${dateStr}T21:00:00+09:00という文字列は、「日本時間(UTC+9)での21時ちょうど」をそのままJavaScriptのDateに伝える書き方です。こう書いておけば、Date側がタイムゾーンの計算を正しく処理してくれるため、サーバーの実行環境がUTCであってもJSTであっても、getTime()で得られる時刻(エポックミリ秒、つまり世界共通の絶対時刻)は変わりません。あとはisPublished側でDate.now()(これも絶対時刻)と単純に比較するだけで、環境のタイムゾーンに影響されない判定ができます。
開発中だけ予約記事も見られるようにする
執筆時のプレビュー用に、ローカルでnext devを動かしている間だけは、予約日付・時刻を無視してすべての記事を表示するようにしています。
if (process.env.NODE_ENV === "development") return true;これにより、本番にはまだ出したくない予約投稿の記事も、手元では公開後と同じ見た目で確認できます。next build/next startやVercel上での本番実行ではNODE_ENVが"production"になるため、この特例は一切効きません。判定条件をNODE_ENVだけに絞ることで、「開発中は確認しやすく、本番には絶対に影響しない」という2つの要件をシンプルに両立させています。
draft: trueとの使い分け
このブログには、記事を非公開にする方法が実質2種類あります。
draft: true:isPublishedの最初の分岐で問答無用にfalseを返す。日付に関係なく完全に非公開にしたい、書きかけの記事や公開時期が未定の記事向けdraft: false+ 未来のdate: 予約投稿。日付が来れば自動で公開される
draft: trueのまま日付だけ未来にしておいても意味は変わりません(draftのチェックが先に評価されるため)。「その日になったら自動で出したい」ならdraft: falseのまま日付を未来にする、「公開日が決まるまで完全に隠しておきたい」ならdraft: trueにする、という使い分けです。
この方式のメリット・デメリット
- メリット: CMSや外部の予約投稿サービスに依存せず、Gitでファイルを管理するだけで完結する
- メリット: frontmatterの日付を変えるだけなので、公開日をずらすのもGit操作の延長で行える
- デメリット: 静的サイト生成(SSG)+ISR(
revalidate = 3600)の構成のため、日付をまたいだ瞬間に自動でページが切り替わるわけではなく、最大1時間ほどのタイムラグが生じる
よくある質問
Q. 記事ごとに公開時刻を変えられますか?
A. 現状の実装では公開時刻は日本時間21時に固定で、記事ごとに変える仕組みは用意していません。時刻を可変にしたい場合は、dateをISO日時形式に変え、PUBLISH_HOUR_JSTのような固定値をやめて、日付から時刻まで読み取るように比較ロジックを拡張する必要があります。
Q. 予約投稿した記事が公開日を過ぎても表示されません
A. 静的サイト生成(SSG)+ISRの構成上、revalidate = 3600によりページの再検証は1時間おきにしか走りません。日本時間21時を過ぎてから最大1時間ほど、表示が切り替わるまでタイムラグが出ることがあります。
Next.jsのApp RouterとPages Routerの違いについてはこちらの記事でも解説しています。