A guest-first form builder where the form is one JSON document.
Try it: clone and npm install && npm run dev · no sign-up, the builder is the
landing page. (Hosted demo coming soon at dietergrosswiler.com/forms.)
Drag questions into place, wire answer choices to reveal later questions, watch the save readout tick while you work. Sign in with one click when you want to keep it, publish to a share link, and responses arrive in your inbox. MIT licensed, one repo, everything you see running is in here.
- Builder · drag-and-drop canvas that doubles as the live preview, undo/redo,
insert-a-question-anywhere dividers, duplicate form, a template gallery of four
starting points, and a keyboard-shortcut sheet on
?. - Logic · answer choices reveal later questions and whole sections; one evaluator runs in the builder preview, the fill page, and the server's submit validation.
- Validation · required, number bounds, choice-count limits, placeholders, rating end labels, and publish-time checks that name the offending question.
- Results · an inbox that renders every response against the exact form version it answered, CSV export, and a Summary tab of per-question charts (bars, histograms, a response-timeline sparkline) aggregated server-side.
- Sharing · publish to a share link with a QR code from a hand-rolled, zero-dependency encoder — Reed-Solomon, mask scoring, the lot.
- Comfort · light and dark themes with a no-flash toggle, autosave with an honest save pill, styled confirm dialogs, loading skeletons, reduced-motion support throughout.
I built a forms engine inside a large proprietary platform that I can't show anyone. Sundew is that idea rebuilt from zero in the open: the concepts carried over, not a line of the code. It is also a set of deliberate second-chances, the things I would do differently with the lessons the first system taught me. Those decisions are the interesting part of this repo, so here they are.
The original system stored forms in normalized tables (sections, questions, options, three junction tables) plus a denormalized JSON snapshot that had to be regenerated on every mutation, carefully merging "master" properties with per-form overrides. That merge was powerful and permanently expensive.
Sundew stores each form as one Zod-validated JSON document (shared/schema.ts). The
document is the source of truth; the server stores it, versions it, and validates it at
trust boundaries. Every section, question, and option carries a stable crypto.randomUUID()
id, so nothing is ever addressed by index and reordering can't corrupt references.
Publishing snapshots the document into an immutable form_versions row. The share link
serves the frozen version until you explicitly publish changes; submissions pin the version
they answered, so the inbox always renders a response against exactly the questions it saw.
The original editor needed a 12-operation optimistic mutation engine because many actors
edited normalized tables through a query cache. Sundew is single-owner, single-document, so
the client holds canonical state in one pure reducer (src/app/builder/state/) with
structural sharing, and persistence is a whole-document PUT with an If-Match revision
header. A conflicting write (another tab) surfaces as an honest 409 instead of a partial
desync. Undo/redo falls out of the reducer for free: history is an array of previous
document references, with text edits coalesced into human-sized steps.
The reducer is hosted in a Zustand store created per builder session, provided through
context and read through selectors. Per-session is the point: the claim flow remounts the
builder from a guest local-* id onto a fresh server id, and a module-level store would
leak one form's history into the next. Zustand contributes subscription plumbing;
dispatch is the only way state changes, so the reducer stays testable with no store in
sight.
Everything that comes back from the API — session (['me']), the workspace list
(['forms']), form details, submissions, version snapshots — sits in a TanStack Query
cache keyed by resource. Mutations invalidate exactly the keys they touch; deleting a form
from the workspace is optimistic, removing the row immediately and rolling back to the
snapshot if the server says no. The best part falls out of publishing being append-only:
a published snapshot (/versions/:v) can never change, so those queries run with
staleTime: Infinity and each version is fetched once per session, ever. The inbox
renders every response against the exact form version it answered, from cache.
One boundary keeps the layers honest: TanStack Query owns request/response server
state; the autosave machine owns the long-lived document write pipeline (debounced
If-Match PUT, 409 conflict handling, offline, keepalive flush). Autosave is deliberately
not a mutation — a write pipeline with that many meaningful states is a state machine, and
hiding it inside a query cache would bury exactly the states the save pill exists to show.
The original used option-side "control tags": selecting an option emitted a string tag, and anything carrying that tag became visible. It worked, but the revealed question never knew why it was visible, and tags were stringly-typed.
Sundew inverts it: the target declares visibleWhen: { mode, rules: [{ when, operator, value }] }, referencing source questions by id. The builder only offers earlier
questions as rule sources, so cycles are impossible by construction and evaluation is a
single forward pass (shared/visibility.ts) shared verbatim by the builder preview, the
fill page, and the Worker's submit validation. Hidden questions keep their draft answers
locally but are masked during evaluation and stripped at submit, client and server alike.
The first autosave I wrote accreted debounce timers, throttle guards, and dirty flags until
every bug fix risked two more. Sundew's autosave is a pure transition function
(src/app/builder/autosave/autosaveMachine.ts): states idle / dirty / saving / savingDirty / error / offline / conflict, events in, { state, effect } out, debounce and
min-interval expressed as data. The caller owns the actual timers. The whole policy is unit
tested with fake clocks in a few milliseconds.
Visitors land straight in the builder with a seeded, fully editable demo form. Guest work
autosaves to localStorage, honestly labeled "Saved in this browser". Signing in (Google or
GitHub OAuth via Arctic, sessions as salted-hash rows in D1, no JWT
machinery) claims the local document into your account. Publishing requires an account, so
every live form has an owner and abuse has an address.
No component library, no CSS framework, no router bigger than the routing. The client
runtime dependencies are react, react-dom, zod, @dnd-kit/*, wouter,
@tanstack/react-query, and zustand — each earning its ~KBs against a hard gzip budget
enforced at build time (npm run check:size). The fill page is a separate chunk that never
loads drag-and-drop or builder code. Styling is hand-rolled on the design tokens of
the site it lives on.
Vite + React 19 SPA and a Hono API in one Cloudflare Worker, with
D1 (SQLite) for persistence. The Worker serves the static assets, the API under
/forms/api/*, publishes share pages with noindex, rate-limits writes and submissions,
and runs a daily cleanup cron.
Prerequisite: Node 22.12+. Everything else, wrangler included, arrives with npm install.
npm install
cp .dev.vars.example .dev.vars # OAuth creds optional; guest mode works without
npm run db:migrate:local
npm run dev # real workerd + local D1 via @cloudflare/vite-pluginTo exercise sign-in and publishing without creating OAuth apps, set E2E_AUTH_STUB=1 in
.dev.vars and restart: the sign-in menu grows a "Continue as test user (dev)" button
that signs in a local account, so publish → share link → fill → inbox is fully testable
offline. Real local OAuth, if you want it, is walked through in
DEPLOY.md.
Tests: npm test (reducer, visibility evaluator, autosave machine, renderer, stats
aggregator, QR encoder, worker helpers) · npm run e2e (Playwright golden path against
the local worker; run
npx playwright install chromium once first — and note e2e writes E2E_AUTH_STUB=1
into your .dev.vars).
You need a free Cloudflare account and npx wrangler login.
npm install # if you haven't already
npx wrangler d1 create sundew # paste the database_id into wrangler.jsonc
npm run db:migrate:remote
npm run deploy # builds, checks bundle budgets, deploysThat alone is a working guest-mode instance at
https://sundew.<your-subdomain>.workers.dev/forms/. Sign-in and publishing need OAuth
apps — without them the app still runs, and the sign-in menu says so honestly:
- Create a Google OAuth client (console.cloud.google.com,
type Web application) and/or a GitHub OAuth app
(github.com/settings/developers). Redirect /
callback URL:
https://sundew.<your-subdomain>.workers.dev/forms/api/auth/<google|github>/callback. - Put the client ids in
wrangler.jsoncundervars.GOOGLE_CLIENT_ID/vars.GITHUB_CLIENT_ID— they ship empty, and an empty id simply hides that provider's button. npx wrangler secret put GOOGLE_CLIENT_SECRETand/orGITHUB_CLIENT_SECRET.npm run deployagain.
Either provider alone is enough. DEPLOY.md is the fuller runbook: CI deploys, GitHub Actions secrets, custom-domain cutover.
Deployment knobs live in wrangler.jsonc: the worker name, the D1 binding
(database_name / database_id), the daily cleanup cron, the OAuth client-id vars, and
a commented-out routes block for serving under your own domain. Secrets go through
wrangler secret put; their local equivalents live in .dev.vars.
Restyling is one file. Every color, shadow, and type token sits in
src/app/styles/tokens.css — a light :root block, a dark :root.theme-dark block,
and semantic --sd-* aliases the components consume. No other file hardcodes a color,
except public/favicon.svg, which carries its own copy of the accent.
Dark is the shipped default; the built-in toggle (top bar, footer, fill page) flips to
light and persists the choice, which an inline script in index.html applies before
first paint. To ship light as the default instead, flip the comparison in that script.
Fonts are self-hosted OFL subsets in public/fonts/ + src/app/styles/fonts.css (mind
the Newsreader metric overrides there if you swap faces).
Renaming it touches: BASE_TITLE in src/app/router.tsx, the title and meta
description in index.html, the wordmark in the top bar and footer copy, the seed demo
form in shared/seed.ts, the report-abuse contact in src/app/pages/FillPage.tsx, and
public/favicon.svg. The sundew: localStorage prefixes and sund_* cookie names can
stay — renaming those just signs users out and orphans guest drafts.
The /forms/ base path is load-bearing. It is deliberately a constant, not a
config value: it appears in vite.config.ts (base), the wouter router base, the API
client, the worker route table and cookie paths, the OAuth returnTo sanitizer,
fonts.css URLs, scripts/postbuild.mjs's asset layout and _headers rules, and
wrangler.jsonc's run_worker_first globs. To mount the app anywhere else,
grep -rn '/forms' across src/, scripts/, e2e/, index.html, vite.config.ts,
and wrangler.jsonc and change every hit — the cookie Path and the returnTo
sanitizer are the two that break sign-in silently if missed.
- File-upload question type (needs R2 + scanning; deliberately out of v1)
- Rich-text question type (deliberately out: a 100 KB editor for a demo nobody grades)
- Response webhooks
MIT · built by Dieter Grosswiler. The bundled font subsets (Fraunces, Newsreader, Spline Sans Mono) are SIL OFL 1.1 — see public/fonts/OFL.txt.

