I manage codenames. Not for any organization that would pay me — just for
myself, and now for whoever else lands on the page. Cryptonym Desk started as a
private CSV that grew into something I wanted to share as a static site. The
problem was that a static site that accepts submissions isn't really static
anymore.
Here's how I rebuilt it on Astro with a public Sanity dataset, where the
review workflow lives inside Sanity itself, and where one thing I assumed
"just works" in a headless CMS almost killed the project.
The dataset as the API
The original architecture was a JSON file generated from a script, served by
Nginx. To add a codename, you edited the JSON, re-ran the build, redeployed.
Worked fine for one person. For zero other people.
Sanity's public dataset feature is what made me try it. You configure a dataset
as public, and any content published to it is readable from the CDN without
authentication. No API gateway, no token rotation, no JWT in a client that
doesn't have users. The client queries it with GROQ, same as any authenticated
Sanity project, but without a token header.
That means a static site generator like Astro can pull all published content at
build time, render pages, and ship zero JavaScript for data fetching — the
pages are just HTML. It's a headless CMS that's also your data lake, your cache
layer, and your read API, all on someone else's CDN.
It also means anyone with the project ID can query your entire published
dataset. That's the trade-off: you get a free global CDN-backed API, and you
give up any notion of private published content. For a public reference site,
that's fine. For something with user accounts, it wouldn't be.
The review gap
The problem showed up the first time someone wanted to submit a codename.
A static site has no form handler. You can put a form on the page and point it
at a serverless function or a third-party service, but then you have to wire
that response into your CMS. The straight path is a webhook that writes to
Sanity, but unauthenticated webhooks that create documents are a production
incident waiting to happen — and a spam pipeline.
What I wanted was: someone submits a word, it lands in a "pending" state
visible only to me, I approve or reject it, and if approved it appears on the
next build.
Sanity has draft and published states per document. That's nearly the workflow,
but drafts are visible to anyone with dataset write access, and a submission
that creates a draft still needs the submitter to have a token. So the pipeline
was: external form -> serverless function -> Sanity mutation with a
write-restricted token -> document in "pending" status.
That's where the queue came in. Instead of trying to make Sanity's document
workflow handle submission review, I kept submissions as a separate document
type, "wordProposal", and built a minimal review interface in a Sanity Studio
dashboard. The App SDK lets you register tools that run inside Studio — I used
it to add a queue view that lists proposals, shows the proposed word and a
submission reason if provided, and lets the editor promote it to a published
codename document or delete it.
What broke
Three things, and two of them I should have caught earlier.
First, the App SDK queue widget and mobile. The dashboard component was a
custom React widget that queried *[_type == "wordProposal"] and rendered a
list with approve/reject buttons. It worked great in the desktop Studio. On
mobile, Sanity's Studio iframe clipped the widget to a narrow column and the
buttons overflowed without scroll. The fix was wrapping the widget in a
responsive container and testing at 360px viewport width before every deploy.
I'd skipped that because "I'll only review from my laptop."
Second, the pinned appId. When you register an App SDK tool, Sanity gives
you an appId. If you don't pin the deployed version in your manifest, Studio
loads whatever you last pushed — which could be a broken build you thought was
just a test. Pinning is one checkbox in the Sanity console, but I missed it
for two weeks and once reviewed proposals against a widget that was querying
the wrong dataset because I'd deployed a dev branch.
Third, Studio as the editorial interface is fine for editors, but it's not a
public submission portal. I spent a week trying to configure the Studio
itself as a submission form — public users landing on a Studio page, filling a
custom input, hitting submit. That doesn't work because Studio requires
authentication. The boundary is clear now: Studio is for people who manage
content. The public form lives on the Astro site. The queue sits between them.
The shape of it now
The final architecture is boring in the best way:
- Astro builds the static pages at deploy time, querying the public Sanity dataset for published codenames.
- Sanity stores both published codenames and pending proposals as separate document types.
- The form is an Astro page with a POST handler pointing to a lightweight serverless function that creates a proposal document with a write-restricted Sanity token.
- The review queue is an App SDK widget registered in the Sanity Studio — a simple list with approve/reject buttons that either creates a published codename from the proposal or deletes it.
- The hosted Studio is the deployed Studio app linked from the Astro site's admin route, so I can open it from anywhere.
The queue is data — a document type and an App SDK widget. No separate queue
service, no message broker, no database. The dataset is the source of truth for
everything, including workflow state.
The whole thing lives at
https://github.com/casareanderson/cryptonym-desk-sanity.
The documentation includes screenshots of the live desk and the corpus page,
and the Studio setup is documented step-by-step because I will forget how I
configured the App SDK tool.
The lesson that transfers
If you're using a headless CMS as your public backend, the review workflow is
not a feature — it's the product. Sanity's draft/published model is designed
for editorial teams, not for public submissions. The moment you open content to
anonymous input, you need a queue between the submitter and the published
document, and that queue has to be part of your content model, not bolted onto
it as webhooks and external databases.
Keep the queue in the dataset. A proposal document type with a status field,
an App SDK widget to process it, and a static site that only reads published
state — that's three things that each do one job and don't require a fourth
thing to coordinate them.
Don't try to make Studio a submission form. It's an editorial tool. Build your
public form separately, and don't assume the mobile layout of a custom Studio
widget works until you've looked at it from a phone.
— The public dataset is the simplest interface. The queue is the one that needs designing.
🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.

Top comments (3)
One privacy check I would add: are wordProposal records real Sanity drafts, or ordinary documents with status: "pending"? In a public dataset, a custom status or document type does not by itself restrict anonymous reads, and a write token does not make those records private. Check with a token-free query for proposals, including submission reasons. If those must stay private, use actual drafts or a private dataset and publish only approved fields. I have not inspected your dataset; this is a boundary to test, not a confirmed exposure.
Yeah, you've put your finger on the exact boundary I should have been clearer about in the post, they're ordinary documents with a
_type == "wordProposal"and no draft wrapper, so anyone with the project ID can read every pending submission. The trade-off I accepted was that the submission form doesn't collect emails, names or accounts, just a word and an optional reason field, so there's nothing in a proposal that identifies a person. Can't make that same call for a site with user accounts.Thanks for clarifying that the pending records are intentionally public. One caveat: an optional free-text reason can still contain a name or email even when the form has no identity fields. I would label both fields as publicly readable before submission, ask people not to include personal information, and keep a removal path for accidental disclosures. If reasons are only useful to the reviewer, keeping those private while publishing the approved word would narrow that boundary further.