Every card adaptive. Every streak earned.
Full learning platform: FSRS spaced repetition, AI-generated flashcards and quizzes from PDFs / notes, gamification with XP / streaks / quests / cosmetics, four Stripe tiers, and a classroom mode for teachers. Next.js 16 App Router · Prisma 6 · Postgres · Redis · Stripe · OpenAI · Soketi. Self-hosted via Coolify on a dedicated Hetzner server behind Cloudflare Edge.
Why this exists.
The established spaced-repetition market has two poles. Anki is algorithmically honest — SM-2 / FSRS, sharp defaults, decades of iteration — but the UI feels like research software from 2008, and gamification is, at best, not present. Quizlet has the look, but the learning algorithm is a glorified ordering — no FSRS, no memory-stability modelling, no claim to catch you at the moment of forgetting.
Flashbuddy closes the gap: an FSRS learning core that runs past Anki on the science side, embedded in a Duolingo-style gamification layer (XP, streaks, streak freezes, daily quests, unlockable cosmetics), wrapped in a modern German-language UI with AI flashcard and quiz generation from PDFs, notes, or free text. Plus a B2B classroom track for teachers with assignments — because the real money is in school, not in hobby learners.
The box it had to fit in.
throw.How it runs.
Five deliberate choices.
One central withApiHandler instead of per-route boilerplate.
withApiHandler<T>(handler, opts, schema) handles auth, Zod validation, CSRF verify, rate limiting, body-size cap, ban check, audit log, Sentry capture, and request-ID propagation in one function.Credits before the AI call, not after.
checkCredits() → AI call → deductCredits() inside the same Prisma transaction with an idempotency keyFSRS instead of SM-2 or a homegrown “smarter” schedule.
Flashcard.fsrs* fieldsLocal Redis before Upstash, with a memory fallback.
redis:7 container · @upstash/redis as failover · a Map as last-resort fallback · a custom sliding-window rate limiterCoolify on a single box instead of Vercel + managed stack.
Coolify on a dedicated Hetzner server orchestrates app + Postgres + Redis + Soketi as Docker services. Push to main → webhook → Coolify rebuilds, Traefik flips the upstream. DB migrations run manually via docker exec.Things that were not obvious.
CLOZE fallback in the front-end validator
{{c1::hidden::hint}}. At one point an AI generator started silently dropping the markers — the CardType stayed CLOZE, but the front side was plain prose. The learner saw nothing to fill in.Fix: on save, downgrade to STANDARD when no markers are detected. No throw, no fail-loud — a silent correction that still leaves the card usable. AI outputs are never schema-faithful; the backend has to clean up what the generator mis-labels. (Commit
e28167c.)NotificationPreference as a gate, not a flag
{ pushEnabled: false } on User. Reality: users want achievement notifications via push, group messages only in-app, and streak warnings by email at 6 pm.Refactor:
NotificationPreference with (userId, type, channel) as a composite key. createNotification() calls isNotificationAllowed() per channel and returns null when the user has opted out. No exception, no Sentry noise: an opted-out user is not an error, just a no-op. 29 notification types × 3 channels = 87 possible user preferences, all default-on and individually opt-out-able. Cuts support tickets in a way you can read off the chart.Stripe webhook idempotent by event id
checkout.session.completed arrives twice — and the first delivery already upgraded the plan; the second would trigger a duplicate credit add.Fix:
CreditTransaction.stripeEventId as a @unique index. The webhook handler reads the event, then writes a signed-and-deduped CreditTransaction carrying the event ID — on duplicate-key error → already processed, return 200, no side effects. The whole subscription flow runs inside the same prisma.$transaction. If the transaction rolls back, the insert rolls back too — Stripe retries and the insert has another real chance. One unique constraint, the rest is database mechanics.CSP nonce from proxy.ts
middleware.ts — proxy.ts is the only hook for per-request logic. The CSP runs nonce-based script-src (no more unsafe-inline), with a fresh nonce per request.proxy.ts sets the x-nonce header; layout.tsx reads it via headers() and passes it to every inline-script tag. Strict CSP without maintaining inline-script hashes — but every new <Script> has to carry the nonce prop or it gets blocked. A Lighthouse drop after a missing nonce caught it live in 30 seconds once.Audit log as a feature, not as compliance
AuditLog row with actor + target + diff.Side effect: every support case (“my cards are gone”) becomes traceable in seconds — cron job? user themselves? admin? The diff shows what was there before. In the admin dashboard that’s a filter on actor id, not a grep over logs. Compliance falls out as a side effect, not the other way around.
Hand-written SQL migrations instead of prisma migrate dev
0_init migration — some whitespace in the generated SQL flips the diff. Rather than fix it: migrations live as hand-written migration.sql files in prisma/migrations/, applied via prisma migrate deploy.Every enum addition uses
IF NOT EXISTS so reruns stay idempotent. Plus, in production npx prisma@6 must be pinned, because Prisma 7 ships breaking config changes — and npx without a pin will pull the latest. A 15-line note in CLAUDE.md, written after the stack caught me twice on migration day.What's running.
What I learned.
A central API handler is the investment.
withApiHandler is the only architectural decision I would have made earlier in hindsight. CSRF, rate limit, audit log, Sentry, body size, ban check, request id — all things that take a weekend to retrofit per-route, but an hour to centralise. I wired four security cross-cuts in after the first live day without touching 200 routes. Building solo, the question “does this boilerplate hurt enough that I should write a helper” is the wrong reflex — the question is “can I afford to forget it once.” For auth, CSRF, and audit, the answer is always no.
Gamification is a retention multiplier.
I was allergic to streaks and XP at first — felt cheap, manipulative, “dark pattern.” After 6 months of data: users with an active streak review 3.8× more often than users without. Not because they want XP, but because the streak badge is an externally visible commitment device — the same mechanism that carries Duolingo. Streak freezes as a “forgiveness mechanic” keep one missed train ride from killing the entire habit loop. Learning is habit engineering, not algorithm engineering. FSRS without a habit layer is brilliant software that nobody opens.