I accidentally built a CMS
January 25, 2026 | Seattle 🌧️
In the post about launching this thing, I mentioned almost in passing that everything “picked up once I built a simple admin view to add artists much faster.” That one sentence was hiding an entire iceberg. Right after I shipped V1, I stopped adding artists through the raw Supabase table view and built myself a little admin. And “little” lasted about two weeks before it turned into a genuine content management system — a data browser, inline editing, moderation queues, the works. I did not set out to build a CMS. I built one anyway, one “ugh, this one thing is annoying, let me fix it” at a time.
It started as a wall of emoji buttons
The first admin was as dumb and honest as it gets: a dashboard that’s literally a grid of links to separate pages. One button per thing I might want to do.
export default function AdminPage() {
return (
<div className={styles.container}>
<h1 className={styles.title}>Admin Dashboard</h1>
<div className={styles.buttonGrid}>
<Link to="/admin/add-artist" className={styles.actionButton}>
<span className={styles.buttonIcon}>👤</span>
<span className={styles.buttonText}>Add Artist</span>
</Link>
<Link to="/admin/add-shop" className={styles.actionButton}>
<span className={styles.buttonIcon}>🏪</span>
<span className={styles.buttonText}>Add Tattoo Shop</span>
</Link>
<Link to="/admin/all-data" className={styles.actionButton}>
<span className={styles.buttonIcon}>📊</span>
<span className={styles.buttonText}>ALL DATA</span>
</Link>
{/* ...add city, add country, link artist/shop, check broken links */}
</div>
</div>
);
}
Add Artist, Add Shop, Add City, Add Country, Link Artist to Shop, Check Broken Links, and one all-caps ALL DATA button. Every one its own page. It’s not elegant. It was never meant to be seen by anyone but me, and it got me off the Supabase web UI, which was the entire goal.
The form that was secretly a form engine
The add-artist page is where the “this is more than a script” creep started. I didn’t want to hand-write validation and submit logic for every entity, so the actual form is tiny — it just declares its fields, how to validate them, and how to shape them for the API, and a shared useAdminForm hook does the rest:
useAdminForm({
initialData: { name: "", instagram_handle: "", city_id: "", shop_id: "", /* ... */ },
transformData: formData => ({
name: formData.name,
instagram_handle: formData.instagram_handle || undefined,
city_id: parseInt(formData.city_id),
shop_id: formData.shop_id ? parseInt(formData.shop_id) : undefined,
}),
validateData: formData =>
!formData.name || !formData.city_id ? "Please fill in all required fields" : null,
onSubmit: async data => addArtist(data),
});
Once that hook existed, adding a form for shops, cities, and countries was basically free — declare the fields and go. That’s the exact moment a “form” becomes “a system,” and it’s always sneaky, because each individual step feels trivial.
Under it, the API client is deliberately boring — a relative fetch to a serverless endpoint that hands back the new ID:
export async function addArtist(data) {
const apiUrl = import.meta.env.VITE_API_URL || "/api/addArtist";
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) { /* ...surface the error... */ }
const result = await response.json();
return result.artist_id;
}
ALL DATA: the screen I actually live in
The ALL DATA button is where it stopped being a data-entry tool and became a CMS. One page, tabs for every entity type, sortable columns, and inline editing right in the table:
type TabType = "artists" | "shops" | "cities" | "countries" | "states" | "new_artists" | "bugs";
const [activeTab, setActiveTab] = useState<TabType>("artists");
const [sortColumn, setSortColumn] = useState<SortColumn>("id");
const [editingArtistId, setEditingArtistId] = useState<number | null>(null);
Click a row, it flips into an editable form in place; sort by any column; jump between artists, shops, cities, countries with a tab. This is the first version of a thing I’d end up rebuilding more than once later, but even this scrappy version changed how I worked overnight. Managing the directory stopped being “write SQL” and became “click around a table.”
Letting strangers do my data entry
The best feature is the one that offloads the work. I was never going to find every good artist myself — so the public site has a “report an issue / suggest an artist” modal, and whatever people submit lands right back in the admin as a moderation queue. The modal does double duty through a single mode prop:
export default function ReportIssueModal({ mode = "report", entityType = "artist", ... }) {
const [editingFields, setEditingFields] = useState<Set<string>>(new Set());
const [suggestedValues, setSuggestedValues] = useState<Record<string, string>>({});
// report mode: suggest a fix to an existing artist/shop, field by field
// new_artist mode: submit a whole new artist for review
In report mode a visitor can suggest edits to specific fields of an existing record; in new_artist mode they can submit a whole new artist. Both flow into the same place — the new_artists and bugs tabs of that ALL DATA view, loaded straight off a submissions endpoint:
} else if (activeTab === "new_artists" || activeTab === "bugs") {
loadSubmissions();
}
// ...
const type = activeTab === "new_artists" ? "new_artist" : "report";
const apiUrl = `${baseUrl}/listSubmissions?type=${type}`;
So the loop closes: someone on Reddit spots that a shop moved, hits the report button, and it shows up in my queue to approve. The directory grows without me being the only pair of hands. For a project whose entire premise is “the slow grind of adding artists one by one,” building the thing that lets other people share the grind was the highest-leverage afternoon I spent.
The accidental part
Nobody decided “let’s build a CMS.” Each step was a small, reasonable “this one screen is annoying, let me fix it” — and two weeks of those add up to a sidebar of tools, a tabbed data browser, inline editing, and a moderation queue. That’s how most of the good internal tools I’ve ever used got built, too: not designed as products, just accreted by the one person who has to live in them every day.
This admin got rebuilt hard later — the wall of buttons became a real sidebar, the inline editor became a slide-out panel, and I bolted on analytics that shamelessly ape Vercel’s dashboard. But honestly, the biggest speedup didn’t come from polishing the admin at all. It came from realizing I could skip it entirely and add artists from the one place I was already standing all day: Instagram. That’s a Chrome extension, and it’s its own story.
What was spinning while writing:
Never Fight a Man with a Perm by IDLES