The Problem
You are a med student with thousands of Anki cards. Making a card by hand takes longer than reading the paragraph it comes from, so the deck always lags behind the lectures. You want a web app where you paste lecture notes, get reviewable cards back in seconds, study them with the keyboard, and still be able to move your cards into Anki when you need to.
The Solution
A Next.js app with Postgres, managed through the prisma skill. The scheduler is SM-2, small enough to keep in one tested function. Cards are generated by Claude through the anthropic-sdk skill, using structured outputs so the response is always valid JSON that matches your schema. Export writes the tab-separated text format that Anki's own importer reads.
Step-by-Step Walkthrough
1. Schema and client
Prisma 7 no longer puts the connection URL in schema.prisma, and the client needs a driver adapter. Install them pinned to the 7 line, because the npm latest tag of prisma currently points at an 8.0 release candidate that does not match @prisma/client 7: npm install -D prisma@7 and npm install @prisma/client@7 @prisma/adapter-pg@7. Then set up three files.
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
decks Deck[]
sessions StudySession[]
}
model Deck {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
name String
cards Card[]
sessions StudySession[]
createdAt DateTime @default(now())
@@index([userId])
}
model Card {
id String @id @default(cuid())
deckId String
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
front String
back String
tags String[] @default([])
reviews CardReview[]
repetitions Int @default(0)
easeFactor Float @default(2.5)
interval Int @default(0)
nextReview DateTime @default(now())
createdAt DateTime @default(now())
@@index([deckId, nextReview])
}
model CardReview {
id String @id @default(cuid())
cardId String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
sessionId String?
quality Int
timeMs Int?
createdAt DateTime @default(now())
}
model StudySession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
deckId String
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
cardsStudied Int @default(0)
correctCount Int @default(0)
createdAt DateTime @default(now())
}
// prisma.config.ts
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: { url: env('DATABASE_URL') },
})
// lib/db.ts
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'
export const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
})
Run npx prisma migrate dev against a database on 127.0.0.1. There is no card "status" column: new, learning or mature follows from repetitions and interval, so it cannot drift out of sync.
2. SM-2 scheduling
The scheduler is a pure function, so you can test it without a database. The study session row is created once when the user starts studying, and every review increments it inside the same transaction as the card update.
// lib/sm2.ts
import { prisma } from './db'
export type Quality = 0 | 1 | 2 | 3 | 4 | 5
export function nextState(
s: { repetitions: number; easeFactor: number; interval: number },
quality: Quality,
now = new Date(),
) {
let { repetitions, interval } = s
if (quality < 3) {
repetitions = 0 // lapse: start the ladder again
interval = 1
} else {
interval = repetitions === 0 ? 1 : repetitions === 1 ? 6 : Math.round(interval * s.easeFactor)
repetitions += 1
}
const easeFactor = Math.max(
1.3,
s.easeFactor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02),
)
const nextReview = new Date(now.getTime() + interval * 86_400_000)
return { repetitions, interval, easeFactor, nextReview }
}
export async function submitReview(
cardId: string, sessionId: string, quality: Quality, timeMs?: number,
) {
const card = await prisma.card.findUniqueOrThrow({ where: { id: cardId } })
const next = nextState(card, quality)
await prisma.$transaction([
prisma.card.update({ where: { id: cardId }, data: next }),
prisma.cardReview.create({ data: { cardId, sessionId, quality, timeMs } }),
prisma.studySession.update({
where: { id: sessionId },
data: {
cardsStudied: { increment: 1 },
correctCount: { increment: quality >= 3 ? 1 : 0 },
},
}),
])
return next
}
Rating the same card 4, 4, 4, 2, 5 gives intervals of 1, 6 and 15 days, then a lapse back to 1 day with the ease factor down to 2.18. Put that sequence in a unit test. SM-2 is a 1990s algorithm; Anki itself now offers the newer FSRS scheduler, and the ts-fsrs package implements it if you want to swap nextState later.
3. AI card generation
Ask Claude for a JSON schema instead of "return only JSON" and regex-parsing the reply. With output_config.format the response conforms to the schema, and the SDK's zodOutputFormat helper turns a zod schema into the format the API accepts, so the same object also gives you the runtime check.
// lib/ai-cards.ts
import Anthropic from '@anthropic-ai/sdk'
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
import { z } from 'zod'
import { prisma } from './db'
const client = new Anthropic() // reads ANTHROPIC_API_KEY
const Cards = z.object({
cards: z.array(z.object({
front: z.string(),
back: z.string(),
tags: z.array(z.string()),
})),
})
const style = {
basic: 'Write questions that test understanding, not recall of wording.',
cloze: 'Write fill-in-the-blank cards: the front has one [...] gap, the back is the missing term.',
definition: 'Front is a term, back is a one-sentence definition.',
}
export async function generateCards(
text: string, deckId: string, count = 10, kind: keyof typeof style = 'basic',
) {
const response = await client.messages.create({
model: 'claude-sonnet-5-5',
max_tokens: 4096,
messages: [{
role: 'user',
content: `Write ${count} flashcards from the notes below. ${style[kind]}\n\n<notes>\n${text}\n</notes>`,
}],
output_config: {
format: zodOutputFormat(Cards), // sends a json_schema the API accepts
},
})
if (response.stop_reason === 'max_tokens') throw new Error('Output cut off; ask for fewer cards')
const block = response.content.find((b) => b.type === 'text')
const { cards } = Cards.parse(JSON.parse(block?.type === 'text' ? block.text : '{}'))
await prisma.card.createMany({ data: cards.map((c) => ({ deckId, ...c })) })
return cards
}
Show the generated cards in a review list before saving them. The model can misread a table or invent a plausible fact, and a wrong card you drill for months is worse than no card. In production, return the cards to the UI first and call createMany only for the ones the student keeps. Model names change; check the current list on the Anthropic models page and keep the name in one constant.
4. Study API
The route handlers below use the Next.js 15+ signature, where params is a promise.
// app/api/study/[deckId]/route.ts
import { prisma } from '@/lib/db'
import { submitReview, type Quality } from '@/lib/sm2'
type Ctx = { params: Promise<{ deckId: string }> }
export async function GET(req: Request, { params }: Ctx) {
const { deckId } = await params
const limit = Math.min(Number(new URL(req.url).searchParams.get('limit') ?? 20), 100)
const cards = await prisma.card.findMany({
where: { deckId, nextReview: { lte: new Date() } },
orderBy: { nextReview: 'asc' },
take: limit,
select: { id: true, front: true, back: true, tags: true },
})
return Response.json({ cards })
}
export async function PUT(_req: Request, { params }: Ctx) {
const { deckId } = await params
const userId = await currentUserId() // your auth layer: returns the signed-in user's id
const session = await prisma.studySession.create({ data: { userId, deckId } })
return Response.json({ sessionId: session.id })
}
export async function POST(req: Request) {
const { cardId, sessionId, quality, timeMs } = await req.json()
if (![0, 1, 2, 3, 4, 5].includes(quality)) {
return Response.json({ error: 'quality must be 0-5' }, { status: 400 })
}
return Response.json(await submitReview(cardId, sessionId, quality as Quality, timeMs))
}
Check that the deck and session belong to the signed-in user in currentUserId() before every write; the sample trusts the client's ids.
5. Keyboard-driven study screen
Keep the keys few enough to learn in a minute: Space flips the card, then 1, 2, 3, 4 rate it. Map them to SM-2 qualities: 1 (Again) sends quality 1, a lapse; 2 (Hard) sends 3; 3 (Good) sends 4; 4 (Easy) sends 5. Attach one keydown listener in the study component, and record timeMs from when the card appears to when the rating key is pressed.
6. Export to Anki
Anki's importer reads plain text with #key:value header lines (separator, html, tags column, deck). That is documented, stable and easy to get right, unlike hand-writing the SQLite schema inside an .apkg file. Use quoted, tab-separated fields so newlines in a card survive.
// lib/anki.ts
import { prisma } from './db'
const cell = (s: string) => `"${s.replaceAll('"', '""')}"`
export async function exportDeckForAnki(deckId: string): Promise<string> {
const deck = await prisma.deck.findUniqueOrThrow({
where: { id: deckId }, include: { cards: true },
})
const rows = deck.cards.map((c) =>
[cell(c.front), cell(c.back), cell(c.tags.join(' '))].join('\t'))
return [
'#separator:tab',
'#html:false',
'#tags column:3',
`#deck:${deck.name}`,
...rows,
].join('\n')
}
Serve it as text/plain with a .txt download name, then in Anki use File, Import. Review history does not travel with this format. Importing from Anki works the same way in reverse: export notes as text from Anki and parse the tab-separated rows into Card records.
Real-World Example
Maya, a second-year medical student, pastes the biochemistry lecture on oxidative phosphorylation into the generator and asks for 15 cards. She deletes four that are too vague, edits one with a wrong number, and saves ten. The next morning her queue shows those ten as due immediately, because new cards start with nextReview at creation time. She rates them with the keyboard in about five minutes. After a good-rated first review, a card returns in one day, then six, then about two weeks. Cards she forgets come back the next day with a lower ease factor, so they are shown more often than the ones she knows. On the weekend she exports her deck as a text file and imports it into Anki on her tablet.
Related Skills
- anthropic-sdk - Messages API and structured outputs for card generation
- prisma - schema, migrations and the typed client for decks, cards and reviews