Forms for university life. An organizer signs in and publishes a form; students open the link and fill it in. No student account, ever.
The alternative on most campuses is a Google Form whose responses land in a spreadsheet nobody owns, shared through a WhatsApp link that half the hostel never sees. It works, barely, and it fails in the same three ways every time: nobody can find the form, the mess feedback isn't really anonymous, and the person who has to act on the results is reading raw rows.
CampusForms keeps the one-link workflow and fixes those three things — a public notice board of open forms, anonymity enforced in the database rather than in the interface, and a results view the hostel office can read in a morning.
For organizers. Sign in with Google, build a form from nine question types, or start from one of fourteen templates written for the job — Hostel Maintenance Complaint, Weekly Mess Feedback, Event Registration, Weekend Leave / Night Pass. Publish, and you get a link plus a printable QR code for the notice board. Results come back as averages, distributions and individual responses, with CSV export for when you want a spreadsheet after all.
For students. Open the link and answer. No sign-in, no app, no account
recovery, nothing stored about who you are beyond what the form itself asks.
Every open form is also listed on /browse, so a form nobody forwarded to you
is still findable.
Categories — hostel, mess, events, academics, general — exist so the notice board can be filtered by the thing a student is actually looking for.
Any form can be marked anonymous. When it is, a database trigger clears the name and email as the row is written — not at display time, not in the query that renders the organizer's screen. By the time the response exists, the identifying fields are already null. The organizer never receives them, and neither does anything downstream, including CSV export.
The schema supports that claim rather than working around it:
- No
submitted_bycolumn and no IP column. They were never added, so there is nothing to leak, subpoena or accidentally join against. - A student can't read their own response back. The confirmation screen
shows a reference number instead, generated client-side before the write.
/statuslooks that code up and returns queue status only — never answers. - Forms carry a trigger-maintained
response_count, which is how a public page can say "37 responses" while nobody but the organizer can read a single one.
Anonymity that only exists in the UI isn't anonymity. The limits of this claim
are stated below, and on /privacy for students.
Next.js 16 App Router with Turbopack, React 19, Tailwind v4, TypeScript, and Supabase for auth, Postgres and row level security.
src/
app/ routes — /, /browse, /f/[id], /login, /dashboard/*, /privacy, /terms
components/ UI, including the form builder and the results summaries
lib/
db/ the only code that talks to Supabase
validation answer + draft rules, shared by the builder and the fill page
analytics response summaries
proxy.ts session refresh and the /dashboard guard
supabase/
migrations/ schema, policies and triggers, in seven ordered files
There is no ORM and no API layer. The browser talks to Postgres through
supabase-js, which means a policy here is not a second line of defence behind
some server that already checked — it is the only line. src/lib/db/ is the
only place that issues a query, so the surface that has to be right stays small
enough to read in one sitting.
The policies are short enough to state in prose. Anyone may read any form, in
any status: /f/[id] needs closed and draft forms to exist in order to explain
why they aren't accepting responses, and nothing in a form definition is
confidential. Anyone may insert a response to an open form. Only the owner may
read, triage or delete responses, or touch the form itself.
The publishable key is in the page source of every form. Anything enforced only in the browser is therefore enforced nowhere — anyone willing to POST to the REST endpoint directly skips it entirely, and that takes about a minute to work out.
So the deadline, a per-form flood limit, and the shape and types of
answers are all triggers. validateFill still runs in the browser, but its job is to give a
student a good error message, not to keep bad rows out.
questions and answers are jsonb. Nothing ever queries a question on
its own — the builder rewrites the whole array per edit, and every summary
iterates it in JS. So a form saves as one upsert, and question ids, which
answers are keyed by, stay stable for free.
deadline is text, not timestamptz. It holds two shapes with different
meanings: 2026-09-25 is the end of that local day, 2026-09-25T18:00 is a
local wall clock. A timestamp column collapses that distinction, and neither
shape carries a timezone to convert from in the first place.
What's next, roughly in the order the campus would feel it.
- Email digests — a nightly "3 new complaints, 2 urgent" to organizers. Needs a service-role Supabase key, an email provider (e.g. Resend) and a Vercel Cron route; none of that exists yet.
- File uploads — a photo of the broken fan beats a paragraph about it.
Supabase Storage bucket, a
filequestion type and anAnswerValueshape for uploaded paths. - AI assist — draft a form from one sentence ("room change requests"), weekly "top themes from 214 responses" summaries, auto-triage of complaint urgency. Needs an LLM API key.
- Live Sheets sync — the CSV is a snapshot; some offices want the spreadsheet.
- Cross-form insights — "all hostel complaints this month" across forms, with the per-question summaries reused.
- Playwright e2e — create → share → submit → triage, run in CI.
Named plainly, because the guarantees above are only worth something if you know where they stop.
- Anonymous means unnamed, not untraceable. Submission time and row order still exist. On a form with very few responses, timing could narrow down who wrote something.
- The flood limit counts per form, not per submitter. Responses carry no IP and no submitter id, on purpose, so there is nothing else to count. It stops a script; it wouldn't stop thirty phones.
- Sign-in is allowlisted. Organizer accounts are added by hand to
allowed_organizers; there is no invitation flow yet.
Built by Nawazish Khan.