Pick your interests, how long you have, and how many people are on your team. Get three buildable hackathon ideas back — each with core features, a concrete stack, a 30-minute MVP plan, and the one moment to show the judges.
Built on Stripe Projects for service provisioning: OpenRouter for generation, Neon Postgres for a small "recently generated" strip.
| Piece | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router, Turbopack) | One process for UI + API, deploys to Vercel unchanged |
| LLM | OpenRouter, free model chain | $0 per request, see Cost posture |
| Database | Neon serverless Postgres (optional) | HTTP driver, no connection pooling to manage |
| Styling | Hand-written CSS | No build step, no framework to configure |
| Abuse controls | proxy.ts + in-process rate limiter |
No new service, no new dependency, see Abuse controls |
| Tests | node --test + tsx |
No test framework dependency |
All on free tiers. Provisioned via stripe projects add:
openrouter/api— LLM accessneon/free— Neon plan (free tier)neon/postgres— the database itself
Credentials come from stripe projects env --pull and land in .env, which is
gitignored. Never hand-edit .env — it is regenerated from the Stripe
Projects vault. See .env.example for the variable names.
npm install
stripe projects env --pull # populates .env
npm run dev # http://localhost:3000Other commands:
npm run typecheck # tsc --noEmit
npm test # 117 unit tests, zero network calls
npm run build # production buildThe provisioned OpenRouter key reports is_free_tier: false, which means a
paid model slug on this key would bill real money. Nothing in this app is
authorised to spend, so lib/openrouter.ts refuses any model slug that does not
end in :free:
assertFreeModel("openai/gpt-4o"); // throws OpenRouterErrorThe guard runs in both directions. assertFreeModel refuses a paid slug before
the call; assertZeroCost refuses the result if a :free slug comes back with
a non-zero usage.cost — which is what a model re-tiered upstream looks like:
assertFreeModel("openai/gpt-4o"); // throws before spending
assertZeroCost("vendor/model:free", 0.0004); // throws after one charge, not every chargeThe second one throws inside the model chain's own try/catch, so the request falls through to the next model and the user still gets ideas. Detecting a charge and returning the result anyway would be detection without enforcement.
To deliberately unlock paid models, set ALLOW_PAID_MODELS=true. That disables
both guards. It is a real money decision — leave it unset unless someone with
budget authority says otherwise. Verified spend after end-to-end testing:
usage_total = 0.
Free models are rate-limited upstream and occasionally return schema-shaped JSON with every field empty. So the client walks a chain and abandons an entry on any failure — transport error, HTTP error, unparseable JSON, or output that parses but yields no usable idea:
nvidia/nemotron-3-super-120b-a12b:free(default; best output quality)google/gemma-4-26b-a4b-it:freegoogle/gemma-4-31b-it:freenvidia/nemotron-nano-9b-v2:free
Override the primary with OPENROUTER_MODEL.
Timeouts are sized to fit inside the 60s maxDuration declared by the API route
— 50s per attempt, 55s total. Overshooting the function ceiling means the
platform hard-kills the request and the user sees a blank 504 instead of our
503-with-explanation. The defaults are identical in local dev and production on
purpose, so local testing predicts production behaviour. Raise them with
GENERATION_ATTEMPT_MS / GENERATION_BUDGET_MS only if the deploy target's
ceiling is genuinely higher.
Two free models were tested and rejected: openai/gpt-oss-20b:free returned
valid JSON with empty pitch/features/mvp_plan, and
google/gemma-4-31b-it:free was rate-limited at the time of testing (it stays in
the chain as a fallback since the limit is transient).
/api/generate is unauthenticated and expensive: one call pins a serverless
function for up to 55s, spends free-tier OpenRouter quota, writes a Neon row,
and adds an entry to the public "recently generated" strip. Roughly eight calls
fill that strip. Without a limit, one while true; do curl ...; done exhausts
all four at once, which is why this exists.
proxy.ts runs before every route under /api and counts requests per client.
| Route | Per minute | Per day |
|---|---|---|
/api/generate |
5 | 50 |
everything else under /api |
30 | 600 |
Over the limit gets a 429 with Retry-After and the same { "error": ... }
body shape as every other API error, so the existing UI renders it unchanged.
Pages are not matched — a client that has spent its budget can still load the
site.
Two details worth knowing before changing anything here:
- The file is
proxy.ts, notmiddleware.ts. Next.js 16 renamed the convention. The exported function must be calledproxy, and settingruntimein that file is an error — Proxy is always Node.js runtime. - Blocked requests do not consume budget. A client over its per-minute limit does not also burn its daily allowance by retrying, so a short burst cannot lock someone out for the rest of the day.
Limits are tunable by environment variable (see .env.example). There is
deliberately no single "disable rate limiting" switch, because that is the kind
of flag that gets set once for a load test and then lives in production forever.
To loosen a limit, raise the number.
What this does not do. Counters live in the process. On a platform running
several instances the real ceiling is instances x limit, and a cold start
resets them. That is a deliberate trade for a hobby-tier app — a shared atomic
store (Upstash, Vercel KV) is the correct answer for a real product, and
swapping it in means reimplementing checkRateLimit against an atomic INCR
without moving any call site. The map is also size-capped at 20,000 keys: an
unbounded map keyed on IP is itself the vulnerability, since one IPv6 /64 offers
2^64 distinct keys. For the same reason IPv6 addresses are grouped by /64 —
per-address keying would let anyone with an IPv6 connection mint unlimited
budgets, which makes the whole limiter decorative.
Client identity comes from x-vercel-forwarded-for, then x-real-ip, then
x-forwarded-for. These are only meaningful because a proxy in front of the app
overwrites them, which holds on Vercel. On a server exposed directly to the
internet a client can supply its own and mint identities — verify that property
before deploying anywhere else. With no such headers at all, every caller shares
one bucket, which fails in the safe direction.
The "recently generated" strip is the one surface where text influenced by an
anonymous caller is shown to every visitor. It is not an XSS hole — React
escapes output and there is no dangerouslySetInnerHTML or eval anywhere in
this repo — the exposure is that an anonymous party can write to a public page.
recentIdeas() sanitises on the way out: URLs, bare www. links and email
addresses are stripped, control characters and bidi overrides are flattened, and
each field is capped (title 80, pitch 160, interest 40, six interests shown).
Sanitising on read rather than write means rows written before this existed are
cleaned too, and the stored record stays a faithful copy of what was generated.
That removes the mechanical payoff — link spam, contact details, text that
renders in a different order than it is stored. It does not moderate meaning;
nothing regex-shaped does. For that case, RECENT_STRIP=off takes the strip
down without a code change. Generations keep being saved either way, they just
stop being displayed.
Persistence is optional, on purpose. The core value is the model call. The
database only powers the "recently generated" strip, so every function in
lib/db.ts swallows its own errors and degrades to a no-op. No connection
string, an unreachable database, or a failed migration must never stop a user
getting ideas. saveGeneration() returns false instead of throwing;
recentIdeas() returns []. /api/recent always returns 200.
Validation has two postures. Request validation is strict — bad input is a client error, reject it with a 400. Model-output validation is tolerant-then-strict: coerce what can be coerced, drop ideas that are still unusable, then throw if nothing survives so the caller can try the next model.
The schema is the contract. lib/schema.ts holds the TypeScript types, the
JSON schema sent to OpenRouter, and both validators. The form, the API route,
and the model call all read from that one file.
Latency is inherent. Free models run ~20–30 tok/s, so a generation takes ~30s. The UI shows a live elapsed counter rather than pretending it is fast.
proxy.ts Rate limiting for every /api route (Next 16 "Proxy")
app/
page.tsx Form + results (client component)
layout.tsx Shell + metadata
globals.css All styling
api/generate/route.ts POST — validate, generate, best-effort save
api/recent/route.ts GET — recent strip, always 200
lib/
schema.ts Types, JSON schema, both validators, prompt builder
openrouter.ts Model chain, cost guards, timeouts
db.ts Optional Neon persistence + public-strip sanitiser
rate-limit.ts Fixed-window counters, bounded memory, IP grouping
tests/
schema.test.ts Validation + prompt behaviour
openrouter.test.ts Cost guards + chain resolution (no network)
openrouter-chain.test.ts Fail-closed cost enforcement, stubbed transport
rate-limit.test.ts Window, rollover, isolation, memory bound
client-key.test.ts Header precedence, port/zone stripping, IPv6 /64
proxy.test.ts End-to-end 429 behaviour against real NextRequests
db.test.ts Public-strip sanitiser + kill switch
Created lazily on first use, memoised per process:
CREATE TABLE IF NOT EXISTS generations (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
interests JSONB NOT NULL,
time_available TEXT NOT NULL,
team_size INTEGER NOT NULL,
model TEXT NOT NULL,
ideas JSONB NOT NULL
);Production URL: https://hackathon-idea-generator-delta.vercel.app
That is the only public address. The Vercel project runs with
ssoProtection.deploymentType = all_except_custom_domains, so the per-deployment
URL and the team-slug alias both 302 to vercel.com/login. Only the assigned
production domain above is reachable without a Vercel account — hand out that
one, and re-check it with an unauthenticated request if the protection setting
is ever changed.
Deployed from a local working copy, no git remote involved:
export VERCEL_TOKEN=... VERCEL_ORG_ID=... VERCEL_PROJECT_ID=... # from .env
vercel deploy --prod --scope <team-slug> -y --no-waitTwo environment variables are set on the Vercel project, Production target only,
both stored as Vercel sensitive variables — they are write-only, so the API
returns an empty string for them and neither vercel env pull nor the dashboard
can read them back. Verifying them means making a real request, not reading the
value:
| Variable | Verified by |
|---|---|
OPENROUTER_API_KEY |
POST /api/generate returned ideas rather than the "not set" 503 |
NEON_POSTGRES_CONNECTION_STRING |
GET /api/recent returned enabled: true with rows |
They are deliberately not set on the preview target: preview URLs are a wider surface than production, and the database credential is the same one. Adding a preview deploy later means adding them there too.
Measured against the live production URL:
- Homepage
200, 8.9 KB. POST /api/generate→200in 12.2s,nvidia/nemotron-3-super-120b-a12b:free(first in the chain),saved: true, 3 ideas. One sample, and comfortably inside the budget — but the local spread below is still the number to plan against, not this one run.- Rate limiting is enforced in production, not merely counted: 20 requests over a
single reused connection gave 8 ×
200then 12 ×429carrying the real message fromproxy.ts. The next request landed on a different instance and passed withX-RateLimit-Remaining: 29— the per-instance ceiling described below, observed in production rather than assumed.
app/api/generate/route.ts sets maxDuration = 60,
the Vercel Hobby ceiling without Fluid compute.
Five measured generations: 23.8s, 29.8s, 42.4s, 44.8s, 49.5s. The spread is wide because free models are shared capacity, and the slowest observed run is already at the 50s per-attempt cap. Two consequences, stated plainly:
- The 55s total budget sits below the 60s ceiling, so a slow model yields a clean 503 with an explanation rather than a platform hard-kill. That safety property holds.
- There is only room for one real attempt inside that budget. The fallback chain only helps when the first model fails fast (bad slug, 429, malformed output) — verified: a bogus primary failed in <1s and the chain recovered on the next model. It does not help when the first model is merely slow.
Occasional 503s under the 60s ceiling are expected, not a bug. If that becomes unacceptable in practice, fix it in order of preference:
- Enable Fluid compute on the Vercel project, raise
maxDurationto 300, and raiseGENERATION_ATTEMPT_MS/GENERATION_BUDGET_MSto match. - Drop
IDEA_COUNTinlib/schema.tsfrom 3 to 2. - Stream the response instead of returning it in one shot.
The rate-limit variables are still unset on Vercel, on purpose. Every one of
them has a default in lib/rate-limit.ts, and those defaults are what the tests
assert; setting them to the same numbers on the platform creates a second source
of truth that can drift without anything failing. To change a limit, set the
variable — that makes the override visible precisely because it is the only one
there. Same for RECENT_STRIP: unset means the strip is on.
The route is public and stays public — there is no auth, by design. What stands between it and abuse:
- Rate limiting in
proxy.ts, per client IP, before any expensive work happens. - Both cost guards in
lib/openrouter.ts, which now refuse a charge rather than log one. - Output sanitising on the public strip, plus
RECENT_STRIP=offas the lever for content a regex cannot judge.
The honest residual risk, in one line: counters are per instance, so a
distributed attacker with many source addresses still gets instances x limit.
If this ever gets real traffic, move checkRateLimit onto a shared atomic
store — that is the one change that closes it.