When building static sites with Astro, Git-based content management is the default. However, having direct browser-based editing during development speeds up the workflow.
You can edit content without switching between editor and browser. Changes reflect immediately on screen. Images can be uploaded via GUI.
For the PromptLex project, I built an editor that only runs in development mode. It has no impact on production builds.
Why Build a Live Editor
Using Git-based management for production is obvious, but development is different.
Testing and content creation became much easier. Iteration speed during prototyping increased, and image management is completed in the same UI. You can easily create and delete dummy data for development testing.
Architecture
It consists of three components.
React component for the UI. Vite middleware for CRUD operation API endpoints. Astro page as the entry point that loads the editor only in DEV mode.
Step 1: Create Admin Page
Create an Astro page that only works in development mode.
---import Base from '../../layouts/Base.astro';import AdminEditor from '../../components/AdminEditor';
const isDev = import.meta.env.DEV;---
<Base title="Admin Panel"> <meta slot="head" name="robots" content="noindex, nofollow" />
{isDev ? ( <AdminEditor client:only="react" /> ) : ( <div> <h1>Admin Panel</h1> <p>The admin panel is only available in development mode.</p> <p>Run <code>npm run dev</code> to access it.</p> </div> )}</Base>Specifying client:only="react" loads the component only on the client side.
Step 2: Build API Middleware
Create an Astro integration that adds API endpoints to the development server.
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 - List all 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 - Create new item 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();}Step 3: Create React Component
Write a React component that calls the API.
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>Loading...</div>;
return ( <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}> <h1>Content Editor</h1>
<form onSubmit={handleCreate}> <textarea value={newText} onChange={(e) => setNewText(e.target.value)} placeholder="Enter content..." rows={4} style={{ width: '100%', padding: '10px' }} /> <button type="submit">Create</button> </form>
<div> <h2>Items ({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> );}Step 4: Register Integration
Add the admin API integration to astro.config.mjs.
import { defineConfig } from 'astro/config';import react from '@astrojs/react';import adminApi from './src/integrations/admin-api';
export default defineConfig({ integrations: [ react(), adminApi(), ],});Advanced Features
File Upload Support
Upload images with multipart form data.
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); // Parse boundary and extract files // ... resolve({ fields, files }); }); });}Duplicate Prevention
Avoid duplicates with content hashing.
import { createHash } from 'crypto';
function generateId(text: string): string { const hash = createHash('sha256'); hash.update(text); return hash.digest('hex').substring(0, 12);}Delete Function
Show confirmation dialog before deletion.
const handleDelete = async (id: string) => { if (!confirm('Delete this item?')) return;
await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems(items.filter(item => item.id !== id));};Best Practices
Always check that it’s development-only with import.meta.env.DEV.
Add noindex meta tag to admin pages.
Make types explicit with TypeScript interfaces.
Write clear error messages.
Validate input before saving.
Use path.join() for file operations.
Impact on Production
No impact on production builds is the advantage of this approach.
The editor only works during astro dev.
Production builds don’t include extra routes.
API endpoints are not exposed.
Static sites remain static.
Building a Live editor for Astro projects changes the development experience. Using Vite middleware and React, you can create a content management interface that only exists during development.
The complete implementation is in the PromptLex repository.
hsb.horse