Admin form post (repeatable shape)
Source: cairn/ts/src/web-server.ts —
/admin/projects,/admin/skills,/admin/experience,/admin/about,/admin/newCategory: Form (server-rendered)
Admin form post — the repeated HTML-form shape that the Cairn admin uses for every content type. Same layout, same submit button, same csrfField() placement, same post-save redirect. Recognizing this shape means any new content type is 30 minutes of work, not half a day.
What it is
Section titled “What it is”A server-rendered form, rendered as an HTML string:
<form method="POST" action="/admin/<type>/<action>"> <input type="hidden" name="_csrf" value="..."> <label>Field 1</label> <input name="field1" value="..."> <label>Field 2</label> <textarea name="field2">...</textarea> <!-- ...per-type fields... --> <button type="submit" class="btn btn-primary">Save</button></form>No React, no Next.js, no SPA. Just an HTML form handled by an Express POST route.
Why it exists
Section titled “Why it exists”The problem: Modern frontend patterns push users toward SPA forms with client-side validation, optimistic updates, and modal flows. For a single-admin CMS:
- All those affordances cost implementation time
- The back button and the refresh button already work correctly with plain HTML forms
- You don’t need to re-learn why POST-Redirect-GET is the right pattern
The fix: embrace the 20-year-old pattern that just works. Save, redirect, render a success banner via query param.
The shape
Section titled “The shape”// List pageapp.get('/admin/projects', requireAdmin, (req, res) => { const data = getData(); const msg = req.query.msg; res.send(adminLayout('Projects', ` ${msg ? `<div class="flash">${esc(String(msg))}</div>` : ''} <h2>Projects</h2> <a class="btn btn-primary" href="/admin/projects/new">Add</a> <ul> ${data.projects.map((p, i) => ` <li> ${esc(p.name)} <a class="btn" href="/admin/projects/edit/${i}">Edit</a> <form method="POST" action="/admin/projects/delete/${i}" style="display:inline"> ${csrfField(req)} <button class="btn btn-danger" onclick="return confirm('Delete?')">Delete</button> </form> </li> `).join('')} </ul> `));});
// New formapp.get('/admin/projects/new', requireAdmin, (req, res) => { res.send(adminLayout('New project', projectForm(req, null)));});
// Edit form (same template, prefilled)app.get('/admin/projects/edit/:idx', requireAdmin, (req, res) => { const data = getData(); const p = data.projects[safeIdx(req.params.idx, data.projects)]; res.send(adminLayout('Edit project', projectForm(req, p)));});
// Shared templatefunction projectForm(req, p) { const action = p ? `/admin/projects/edit/${p.index}` : '/admin/projects/new'; return ` <form method="POST" action="${action}"> ${csrfField(req)} <label>Name</label> <input type="text" name="name" value="${esc(p?.name ?? '')}" required> <label>Link</label> <input type="text" name="link" value="${esc(p?.link ?? '')}" placeholder="example.com/path (scheme optional)"> <label>Description</label> <textarea name="desc" rows="3">${esc(p?.desc ?? '')}</textarea> <label>Tech (comma separated)</label> <input type="text" name="tech" value="${esc(p?.tech?.join(', ') ?? '')}"> <button type="submit" class="btn btn-primary">Save</button> </form> `;}
// Save handlers (same shape for new/edit)app.post('/admin/projects/new', requireAdmin, (req, res) => { const data = getData(); data.projects.push({ name: b(req, 'name'), link: normalizeLink(b(req, 'link')), desc: b(req, 'desc'), tech: b(req, 'tech').split(',').map(s => s.trim()).filter(Boolean), }); saveData(data); res.redirect('/admin/projects?msg=created');});How it’s used
Section titled “How it’s used”- Cairn — same template shape for
/admin/projects,/admin/skills,/admin/experience,/admin/new(blog posts),/admin/about - Pattern generalizes to any single-admin server-rendered CMS
Gotchas
Section titled “Gotchas”- Escape every interpolation. The template is string concatenation; one unescaped value = stored XSS. Route every insertion through
esc(). - POST-Redirect-GET. Always redirect after a successful POST. Otherwise refresh re-submits the form.
- CSRF on every mutating form.
${csrfField(req)}is part of the template. Forget it once and that form becomes a CSRF vulnerability. - Required fields. Use HTML5
requiredwhere possible. Server validation is still mandatory — browsers can lie. - Cancel buttons go somewhere. A Cancel
<a>withhrefback to the list page is less confusing than an ambiguous “close modal” button. - Long-form content editors.
<textarea>is fine for intro paragraphs. If you need Markdown editing with preview, you’ve outgrown this pattern — either ship a small JS-enhanced editor for that one field or accept unadorned textarea. - Per-field errors. Plain forms don’t have a natural error state. The simplest pattern is
?msg=error-in-Xon redirect, banner on the list page. Prettier is re-rendering the form with anerrorvariable. - Auto-save is unnecessary. Save is one button click. Auto-save adds complexity with no real win for a CMS where every content change is deliberate.
See also
Section titled “See also”- patterns/hand-rolled-express-admin-cms — the broader pattern this embodies
- patterns/csrf-for-hand-rolled-admin — the guard the form relies on