Building Flashcardy, a SaaS for Turning Study Material into Practice
How I built Flashcardy, an AI study deck SaaS that turns messy notes, PDFs, slides, and tutor material into editable flashcards.
I started building Flashcardy because my French lessons were creating a second job.
My tutor would send useful material, but it never arrived in one perfect format. Sometimes it was a vocabulary list. Sometimes it was a PDF. Sometimes it was a slide deck, a few copied notes, or a document with verbs, nouns, examples, and grammar rules all mixed together.
The studying part was fine. The cleanup was annoying.
I didn't want to spend 40 minutes turning a lesson into cards before I could review it. I wanted to drop the material into a web app, get a reasonable study deck back, edit the weird bits, and move on.
That sounds small, but it is exactly the kind of small problem that becomes a product once you care about the details.
The product shape
Flashcardy is a SaaS for building and reviewing study decks. You can create categories, decks, and cards by hand for free. The paid path adds imports and AI: paste notes, upload files, ask Flashcardy to improve a deck, or let it plan where new material should go.
The main product decision was that AI should not be a chat box bolted onto a flashcard app.
I wanted the AI to do a narrower job:
- read messy study material
- infer what kind of subject it is
- decide whether to create or update categories and decks
- generate useful cards
- show the plan before changing the library
That last part matters. A lot of AI study tools treat generation as the final step. Flashcardy treats it as a draft. The user still owns the library.
The current flow looks like this:
- Add material with pasted text, files, or a short instruction.
- Flashcardy analyzes the input and builds a plan.
- The plan says which category and decks it wants to create or update.
- The user applies it.
- The generated cards land in the deck editor and review flow.
For my French use case, that means a lesson about à, au, à la, and aux can become a focused deck instead of disappearing into one giant "French notes" pile. For someone else, the same flow can handle biology outlines or certification prep. The product is not French-only. French was just the problem sharp enough to make me build it.
Why existing tools did not fit
Anki is powerful. I still respect it. But power was not the gap.
The gap was getting from raw lesson material to reviewable cards quickly, without giving up control. Most tools I tried had one of two problems. Either they expected me to do the structuring myself, or they generated cards that felt detached from the source material.
I wanted something more opinionated:
- source material should stay attached to generated cards
- card types should match the subject
- imports should be reviewable before they mutate the library
- manual editing should stay first-class
For language learning, card shape matters. A direct translation card is different from a reverse recall card. A gender card is different from a sentence pattern. A conjugation card is different from a context card.
That is why Flashcardy has a typed list of card kinds instead of treating every card as generic front/back text:
export const deckCardKindValues = [
'translation',
'reverse',
'cloze',
'conjugation',
'context',
'definition',
'example',
'comparison',
'sequence',
'classification',
'gender',
'sentence',
] as constThat list is not perfect. It will probably change. But it forces the system to think in study primitives instead of plain text completion.
The AI planner
The most important technical piece is the planner.
Flashcardy does not immediately write generated output into the database. It first builds an AiMutationPlan. The plan contains a source summary, inferred subject metadata, warnings, rationale, a category action, and one or more deck actions.
The simplified shape looks like this:
export const aiMutationPlanSchema = z.object({
mode: z.enum(['add', 'category_improve', 'deck_improve']),
sourceSummary: aiPlannerSourceSummarySchema,
inference: studyInferenceSchema,
warnings: z.array(z.string()),
rationale: z.array(z.string()),
categoryAction: aiPlannerCategoryActionSchema,
deckActions: z.array(aiPlannerDeckActionSchema),
userPrompt: z.string().nullable(),
})There are three modes:
add: bring in new material and decide where it belongscategory_improve: improve the framing and decks inside a categorydeck_improve: expand or repair a single deck
This made the product feel much less random. If the AI wants to create a new category, it has to say so. If it wants to update an existing deck, that is represented as an action. If the source extraction was incomplete, the warnings travel with the plan.
It is still AI. It can still make bad calls. But it makes those calls in a structure I can inspect and constrain.
The stack
Flashcardy is built with TanStack Start, React, Drizzle, and Cloudflare.
The production stack is:
- Cloudflare Workers for the app runtime
- D1 for user, category, deck, card, billing, and import metadata
- R2 for uploaded source files
- Durable Objects for longer-running import jobs
- Better Auth with Google sign-in
- Polar for the paid plan
- OpenAI-compatible structured outputs for the primary generation path
- Workers AI as a fallback and for file-to-Markdown extraction
The local stack is intentionally different. Local development can use the installed Codex CLI. That lets me test generation against real notes without pretending every development loop is production. The Codex runner is isolated behind a helper that passes prompts through stdin and validates the output against a schema.
export function buildLocalCodexExecArgs(schemaPath: string) {
return [
'exec',
'--json',
'--ephemeral',
'--skip-git-repo-check',
'--output-schema',
schemaPath,
'-',
]
}That local path has paid for itself several times. It is much faster to tune the planner against actual French notes when I can stay in the local app and keep the database disposable.
Importing real files is the annoying part
The first version of an AI flashcard app can cheat: accept pasted text and call a model.
The SaaS version cannot cheat as much. People have PDFs, .docx files, .pptx slides, copied notes, and mixed inputs. My tutor material alone was enough to make plain text input feel too limited.
Flashcardy normalizes input into a SourceDocument:
export const sourceDocumentSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
sourceType: z.enum(['paste', 'docx', 'pdf', 'pptx', 'text', 'mixed']),
originalFileName: z.string().optional(),
extractedText: z.string().min(1),
sections: z.array(sectionSchema),
createdAt: z.string(),
warnings: z.array(z.string()).default([]),
})Locally, .docx files are parsed from their OOXML document structure first, then fall back to macOS textutil when needed. PDFs use pdf-parse. Slides are unpacked with jszip and parsed from their XML. In production, files go through R2, and PDF extraction can use Workers AI's Markdown conversion.
The point is not to extract perfectly. The point is to extract enough structure that the planner can make a decent call. If extraction is imperfect, the warnings are stored and shown instead of being swallowed.
Data model
The database model is deliberately plain:
- categories organize decks
- decks hold cards
- cards keep prompts, answers, tags, hints, and source snippets
- imports store extracted source material
deck_importslinks generated decks back to their source
I wanted generated cards to remember where they came from. That matters when the user edits a card and still wants to know the original sentence or note that produced it.
The library itself is split between user-owned content and a public trial library. Guests can explore the trial deck. Signed-in users can build manual decks. Pro users can use imports, AI planning, and deck improvement.
That access model is intentionally simple:
- guest: preview the product
- free user: build manually
- pro user: import and generate with AI
No enterprise matrix. No half-finished team model. Just enough SaaS structure for the product that exists now.
What changed when it became a SaaS
The personal version could be rough. A hosted product needs a few boring things to work every time.
Auth has to be predictable. Billing has to recover cleanly after checkout. Imports cannot hang the request while a model chews on a PDF. The deploy path has to be repeatable. The public site has to explain the product without sounding like every other AI study tool.
That is where Cloudflare has been a good fit. Workers keep the app simple to deploy. D1 is enough database for the current shape. R2 handles uploaded files. Durable Objects give import jobs a place to live while the user polls for status.
The import job stages are plain because the user experience is plain:
export type ImportJobStage =
| 'queued'
| 'extracting'
| 'generating'
| 'persisting'
| 'completed'
| 'failed'No magic there. Just names for the work the app is doing.
The product opinion
The biggest product opinion in Flashcardy is that more cards are not automatically better.
Bad flashcards are worse than no flashcards because they create fake progress. You click through them, feel productive, and remember nothing later.
So the product keeps pushing toward smaller decisions:
- split broad material into smaller decks
- keep prompts answerable
- preserve source snippets when they help
- let the user edit everything
- make AI explain its planned changes before applying them
That is also why the UI is built around the library and the current deck, not a giant dashboard. The product should feel like a place to return to for study, not a control panel for managing AI output.
Where it is now
Flashcardy is live at flashcardy.app. It has Google sign-in, manual deck building, a public trial library, AI-assisted imports, a planner flow, deck improvement, and billing.
It is still early. I already know some parts will change. Review scheduling needs more attention. The planner can get better at merging related material. Some card types need stricter generation rules. Mobile review can always be faster.
But it is past the toy stage, which is the part I care about.
I built it because I wanted to relearn French without spending my study time doing clerical work. The SaaS version keeps that original reason, then makes it useful for anyone with the same basic problem: too much useful material, not enough clean practice.