Astroで静的サイトを作るとき、コンテンツ管理はGitベースが基本だ。 ただ開発中にブラウザ上で直接編集できると作業がはかどる。
エディタとブラウザを行き来せずコンテンツを編集できる。 変更したらすぐ画面に反映される。 画像もGUI経由でアップロードできる。
PromptLexというプロジェクトで、開発モード限定で動くエディタを作った。 本番ビルドには一切影響しない。
なぜLiveエディタを作ったか
本番でGitベースを使うのは当然として、開発中は別だ。
テストもコンテンツ作成もずっと楽になった。 プロトタイピング時の反復速度が上がり、画像管理も同じUIで完結する。 開発テスト用のダミーデータを手軽に作って消せる。
アーキテクチャ
3つのコンポーネントで構成している。
ReactコンポーネントがUI。 ViteミドルウェアがCRUD操作用のAPIエンドポイント。 AstroページがエディタをDEVモード時だけ読み込むエントリーポイント。
ステップ1: 管理画面ページの作成
開発モードでのみ動くAstroページを作る。
---import Base from '../../layouts/Base.astro';import AdminEditor from '../../components/AdminEditor';
const isDev = import.meta.env.DEV;---
<Base title="管理画面"> <meta slot="head" name="robots" content="noindex, nofollow" />
{isDev ? ( <AdminEditor client:only="react" /> ) : ( <div> <h1>管理画面</h1> <p>管理画面は開発モードでのみ利用できます。</p> <p><code>npm run dev</code>を実行してアクセスしてください。</p> </div> )}</Base>client:only="react"を指定すると、コンポーネントがクライアント側だけで読み込まれる。
ステップ2: APIミドルウェアの構築
開発サーバーにAPIエンドポイントを追加するAstroインテグレーションを作る。
import type { AstroIntegration } from 'astro';import { readFileSync, writeFileSync, readdirSync } from 'fs';import { join } from 'path';
export default function adminApi(): AstroIntegration { return { name: 'admin-api', hooks: { 'astro:server:setup': ({ server }) => { server.middlewares.use(async (req, res, next) => { const url = req.url || ''; const method = req.method || 'GET';
if (!url.startsWith('/api/')) { return next(); }
const contentDir = join(process.cwd(), 'src', 'content');
// GET /api/items - 全アイテムの一覧 if (url === '/api/items' && method === 'GET') { const files = readdirSync(contentDir).filter(f => f.endsWith('.json')); const items = files.map(file => { const content = readFileSync(join(contentDir, file), 'utf-8'); return JSON.parse(content); }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(items)); return; }
// POST /api/items - 新規アイテムの作成 if (url === '/api/items' && method === 'POST') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { const data = JSON.parse(body); const id = generateId(data.text); const filePath = join(contentDir, `${id}.json`);
const item = { id, text: data.text, createdAt: new Date().toISOString(), };
writeFileSync(filePath, JSON.stringify(item, null, 2)); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(item)); }); return; }
res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }); }, }, };}
function generateId(text: string): string { return text.slice(0, 12).replace(/\W/g, '').toLowerCase();}ステップ3: Reactコンポーネントの作成
APIを呼び出すReactコンポーネントを書く。
import React, { useState, useEffect } from 'react';
interface Item { id: string; text: string; createdAt: string;}
export default function AdminEditor() { const [items, setItems] = useState<Item[]>([]); const [newText, setNewText] = useState(''); const [loading, setLoading] = useState(true);
useEffect(() => { fetchItems(); }, []);
const fetchItems = async () => { const response = await fetch('/api/items'); const data = await response.json(); setItems(data); setLoading(false); };
const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); if (!newText.trim()) return;
const response = await fetch('/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: newText }), });
const newItem = await response.json(); setItems([newItem, ...items]); setNewText(''); };
if (loading) return <div>読み込み中...</div>;
return ( <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}> <h1>コンテンツエディタ</h1>
<form onSubmit={handleCreate}> <textarea value={newText} onChange={(e) => setNewText(e.target.value)} placeholder="コンテンツを入力..." rows={4} style={{ width: '100%', padding: '10px' }} /> <button type="submit">作成</button> </form>
<div> <h2>アイテム ({items.length})</h2> {items.map(item => ( <div key={item.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0' }}> <strong>{item.id}</strong> <p>{item.text}</p> <small>{new Date(item.createdAt).toLocaleString()}</small> </div> ))} </div> </div> );}ステップ4: インテグレーションの登録
astro.config.mjsに管理APIインテグレーションを追加する。
import { defineConfig } from 'astro/config';import react from '@astrojs/react';import adminApi from './src/integrations/admin-api';
export default defineConfig({ integrations: [ react(), adminApi(), ],});高度な機能
ファイルアップロード対応
マルチパートフォームデータで画像をアップロードする。
async function parseMultipart(req) { return new Promise((resolve) => { const chunks = []; req.on('data', chunk => chunks.push(chunk)); req.on('end', () => { const buffer = Buffer.concat(chunks); // boundaryを解析してファイルを抽出 // ... resolve({ fields, files }); }); });}重複防止
コンテンツハッシュで重複を回避する。
import { createHash } from 'crypto';
function generateId(text: string): string { const hash = createHash('sha256'); hash.update(text); return hash.digest('hex').substring(0, 12);}削除機能
削除前に確認ダイアログを出す。
const handleDelete = async (id: string) => { if (!confirm('このアイテムを削除しますか?')) return;
await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems(items.filter(item => item.id !== id));};ベストプラクティス
開発専用であることをimport.meta.env.DEVで必ず確認する。
管理ページにはnoindexメタタグをつける。
TypeScriptインターフェースで型を明示する。
エラーメッセージはわかりやすく書く。
保存前に入力を検証する。
ファイル操作にはpath.join()を使う。
本番環境への影響
本番ビルドに影響しないのがこの方法の利点だ。
エディタはastro dev中のみ動作する。
本番ビルドに余計なルートは含まれない。
APIエンドポイントも公開されない。
静的サイトは静的のままだ。
Astroプロジェクト用のLiveエディタを作ると開発体験が変わる。 ViteミドルウェアとReactを使えば、開発中だけ存在するコンテンツ管理インターフェースを作れる。
完全な実装はPromptLexリポジトリにある。
hsb.horse