はじめに
サーバーを一切用意せず、GitHub PagesとSupabaseだけで動く匿名掲示板「40ちゃんねる」を作りました。スレッドを立てる仕組みはなく、1つの場所にみんなでコメントを投稿していくだけの、いわゆる「単発ID無し掲示板」です。
この記事では、使用した技術構成と実装のポイントをまとめます。
デモサイト: https://40channel.com
技術構成
- フロントエンド: HTML / CSS / JavaScript(フレームワークなし、単一ファイル)
- ホスティング: GitHub Pages(独自ドメイン運用)
- データベース: Supabase(PostgreSQL)
- リアルタイム通信: Supabase Realtime
- 画像アップロード: ImgBB API
- 広告: 忍者AdMax
サーバーサイドのコードは一切書いていません。ブラウザから直接Supabaseにアクセスして読み書きし、投稿があった瞬間に他のユーザーの画面にも反映される、という構成です。
データベース設計
Supabaseで用意したテーブルは主に2つです。
コメント本体(comments)
create table comments (
id bigint generated by default as identity primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
text text not null,
ip_address text not null,
image_url text
);
alter table comments enable row level security;
create policy "Allow public read" on comments for select using (true);
create policy "Allow public insert" on comments for insert with check (true);
匿名掲示板なのでログイン機能はなく、insertとselectを誰でもできるようにRLS(Row Level Security)のポリシーを設定しています。
ip_addressは画面には一切表示しませんが、荒らし対策や開示請求への対応のためにサーバー(Supabase)側には記録しています。
削除依頼(delete_requests)
create table delete_requests (
id bigint generated by default as identity primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
comment_id bigint,
reason text not null,
status text default 'pending' not null
);
alter table delete_requests enable row level security;
create policy "Allow public insert" on delete_requests for insert with check (true);
このテーブルはinsertしかポリシーを許可していません。つまり、誰でも削除依頼は送信できますが、送信された依頼一覧を見られるのは運営者がSupabaseの管理画面(Table Editor)にログインした場合のみです。実際の削除は自動化せず、運営者が内容を確認してから手動でコメントを消す、という設計にしました。匿名掲示板は自動化しすぎると悪用のリスクが上がるため、あえて人の目を挟むようにしています。
リアルタイム更新の実装
投稿があった瞬間に他のユーザーの画面にも反映されるように、Supabase Realtimeを使っています。
client
.channel("comments-realtime")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "comments" },
(payload) => {
const row = payload.new;
// 受信したコメントを配列に追加して再描画
allComments.push(row);
renderComments();
}
)
.on(
"postgres_changes",
{ event: "DELETE", schema: "public", table: "comments" },
(payload) => {
const oldRow = payload.old;
allComments = allComments.filter((c) => c.id !== oldRow.id);
renderComments();
}
)
.subscribe();
DELETEイベントをきちんと受け取るには、テーブル側の設定が1つ必要でした。
alter publication supabase_realtime add table comments;
alter table comments replica identity full;
replica identity fullを設定しないと、削除イベントのペイロードにidが含まれず、どのコメントが消えたのか判別できませんでした。ここは少しハマったポイントです。
URLの自動リンク化
コメント本文中にhttps://から始まるURLがあれば、自動的にクリック可能なリンクに変換しています。http://のみのURLは意図的にリンク化していません。
function linkifyAndEscape(text) {
const escaped = escapeHtml(text);
return escaped.replace(/(https:\/\/[^\s<>"]+)/g, (match) => {
return `<a href="#" class="external-link" data-url="${match}">${match}</a>`;
});
}
リンクをクリックした際にいきなり別サイトへ飛ばすのではなく、「このサイトを離れますか?リンク先の安全性については保障しません。」という確認モーダルを一度挟むようにしています。匿名掲示板は悪意あるリンクが貼られる可能性もあるため、ワンクッション入れることでユーザーに注意を促す狙いです。
#コメントIDによる返信表示
2chのアンカー機能(>>7のような記法)に近いものを、簡易的に実装しました。コメント本文中に#7のように書くと、そのコメントIDへの返信として認識され、コメントの先頭に返信バッジが表示されます。
function renderReplyBadge(text) {
const m = text.match(/#(\d+)/);
if (!m) return "";
const targetId = m[1];
const exists = allComments.some((c) => String(c.id) === targetId);
if (exists) {
return `<div class="reply-badge">>> <a href="#comment-${targetId}" class="reply-link" data-target="${targetId}">${targetId}</a> への返信</div>`;
}
return `<div class="reply-badge">>> ${targetId}(該当コメントが見つかりません)</div>`;
}
DBに返信関係を保存する仕組みではなく、表示側で本文中の#数字を毎回解析しているだけのシンプルな作りです。バッジのリンクをクリックすると該当コメントまでスクロールし、一瞬ハイライト表示させています。
ポイントは、レス番号を投稿順の連番ではなく、Supabaseのcommentsテーブルの実際のidをそのまま表示するようにしたことです。これにより、削除依頼で使うIDと画面上の表示が完全に一致し、#7と書けばそのまま該当コメントを参照できます。
画像アップロード(ImgBB)
画像はSupabaseには保存せず、ImgBBにアップロードしてURLだけをcomments.image_urlに保存する構成にしました。
async function uploadImageToImgbb(file) {
const formData = new FormData();
formData.append("image", file);
const res = await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`, {
method: "POST",
body: formData,
});
const json = await res.json();
if (!json.success) throw new Error("ImgBB upload failed");
return json.data.url;
}
Supabaseの無料枠にはストレージ容量の制限があるため、画像は外部サービスに逃がすことでDB容量を圧迫しない設計にしています。
年齢確認・利用規約同意のモーダル
匿名掲示板という性質上、以下の2つのモーダルを実装しました。
- 年齢確認モーダル: ページを開くと最前面に表示され、「いいえ」を選ぶとコンテンツが完全に隠れて閲覧できなくなる
- 投稿確認モーダル: 「書き込む」を押すと、利用規約への同意とIPアドレス記録・開示の可能性について案内するモーダルが表示される
どちらもブラウザに結果を保存していません。年齢確認はページを開くたびに毎回表示され、投稿確認モーダルは「そのページを開いてから最初の投稿時にだけ表示し、同じページを開いている間は再表示しない」という仕様にしています。localStorageのような永続化は一切使わず、ページ内のJavaScript変数だけでフラグ管理をしているため、タブを閉じれば必ずリセットされます。
まとめ
- サーバーを持たなくても、Supabase + GitHub Pagesだけで匿名掲示板が作れる
- Row Level Security(RLS)をうまく使うことで、認証なしでも安全に読み書きの権限を制御できる
- Supabase Realtimeを使うと、ポーリングなしで簡単にリアルタイム更新が実装できる
- 削除機能のような「悪用されると困る操作」は、あえて自動化せず人の確認を挟む設計にするのも一つの選択肢
今後は、NGワードフィルタやレート制限など、荒らし対策まわりも強化していきたいと思っています。