Ravnary

An open library of short, checked cards with quizzes graded on the server, a review queue that lives in your browser, and community figures that never name a reader.

Two black ravens perched on a pale rock, in black and white

At a glance

App
Next.js 15 · React 19
Data
DynamoDB · 2 tables
Hosting
Amplify Hosting
Infra
AWS CDK · IAM

Built with

  • Next.js
  • React
  • DynamoDB
  • Amplify
  • AWS CDK
  • IAM

Read one card, then the next

Ravnary is a library where every page holds one short, checked idea. You read a card, then the next, and a quiz is there whenever you want to test yourself. You don't need an account, and there's nothing to sign in to.

The name comes from Odin's ravens. Huginn carries the thought out and Muninn brings it back, which is the loop the site is built around. It's a Next.js app on AWS, and most of the design work went into two things, publishing content atomically and keeping answers and reader data away from the browser.

Open the library →
Address:ravnary.com
Ravnary home page with the headline Read one card. Then the next, and the library counts beside it
The home page leads with subjects, then paths, then the library by field.

The learning loop

Every topic is built for the same five steps. The reader decides when to switch from reading to answering, and the quiz is always optional.

  1. Learn a concept
  2. See an example or a gotcha
  3. Answer a quiz
  4. Read the explanation
  5. Continue or review

Eight kinds of card

Concept, example, walkthrough, comparison, gotcha, code, exercise, and recap. They're declared once, in reading order, and the import schema refuses any type it doesn't know before anything is written.

Seven kinds of question

Multiple choice, true or false, multiple select, ordered steps, fill in the blank, numeric, and matching. A quiz is its own model, separate from cards, and every answer comes with an explanation.

Static pages, one dynamic route, two tables

Content changes only when I import it, so almost everything is prerendered. The server does three jobs at request time. It serves the quiz, runs the server actions, and accepts analytics events. The CDK stack owns the tables and the roles.

built from

GetItem and Query

append raw events only

read published aggregates only

new generation

rebuild webhook

👤 Reader

☁️ Amplify Hosting and CloudFront

📄 Prerendered pages
every content route

⚙️ SSR compute
quiz route · server actions · /api/events

🗄️ ravnary-content
published generation

📊 ravnary-analytics
raw events expire in 90 days

🧰 content:import CLI

🧮 Aggregation

🏗️ AWS CDK

🔐 IAM roles
leading-key conditions

Prerendered by default

Every content route is static. The build reads DynamoDB, so a content import asks Amplify to rebuild the site through an incoming webhook. Amplify Hosting doesn't support on-demand revalidation.

The quiz is the one exception

It draws a new seed and reshuffles its questions on every request. Prerendering it would freeze one question order for good, which would change the product.

Two tables that never join

Content is immutable between imports and has point-in-time recovery. Events are append-only, expire after 90 days, and have no backups, so the two live in separate tables.

Three fixed levels and a graph beside them

A subject owns its topics and a topic owns its cards and questions. The depth never changes, so no cycle can form and the URL spells the hierarchy as /learn/subject/topic/card. Paths, categories, and links between cards cross the hierarchy, so they're stored beside it.

optional

names

names

tags

prerequisite · related · next

📚 Subject

🔖 Section
an anchor, not a page

🧩 Topic

🃏 Cards
8 types in a fixed order

❓ Questions
7 types

🧭 Path
editorial order

🕸️ Category graph
a node may have many parents

A path is an order over existing places

A path puts subjects and topics in an order across the library. The ?path= parameter only records where the reader came from and never changes what a page contains. The browser carries the journey, so every page stays prerendered.

Categories are a graph, not a tree

A card tagged react shows up under Web Development and under Technology without moving. The importer precomputes every ancestor, so the database never walks the graph while someone waits.

Address:ravnary.com/learn/java-concurrency/deadlock
Topic page for Deadlock and the Failures Near It, with card and question counts, difficulty, and a Start reading button
A topic page states what it holds before you start.
Address:ravnary.com/paths/from-a-first-class-to-a-thread-pool
Learning path page listing three subject steps with a progress bar
A path orders subjects that live elsewhere in the library.
Address:ravnary.com/categories
Categories page with five fields and the number of topics filed under each category
Categories come from a taxonomy kept in Git and resolved at import.

Publishing a whole generation at once

An import builds a complete new generation of the read model next to the live one and flips a single pointer last. Readers see the old library until the flip and the new one after it.

☁️ Amplify🗄️ ravnary-content🧰 content:import✍️ Author☁️ Amplify🗄️ ravnary-content🧰 content:import✍️ AuthorReaders stay on generation nalt[Another import won the race][Pointer flipped]JSON document1Validate with Zod, reject before any write2Read every canonical record, strongly consistent3Merge and build the whole read model in memory4Atomic ADD reserves generation n+15Write canonical records, then generation n+16Flip the pointer to n+1 only if it still says n7Condition failed, exit 18POST rebuild webhook9Build prerenders every page from n+110

Key design

Every page read is a GetItem or a prefix Query

The importer resolves joins and counts ahead of time, so the table needs no secondary index. A slug lookup is one GetItem. The whole category graph is a single 41 KB item.

CONTENT            POINTER
CANONICAL          TOPIC#id
Vn#SLUG            TOPIC#subject#topic
Vn#TOPIC#id        CARD#id
Vn#QUESTION#id     PROMPT

Safe to interrupt, safe to race

An import that dies halfway leaves an unreferenced generation and an unchanged site, and running it again repairs it. Two imports can't both win, because each one reserves its generation number with an atomic counter first.

Visibility is decided once

Drafts live only in the canonical records. The read model holds just what's published and reachable, so no page filters on status and the previous generation stays around for a rollback.

Cards that read without JavaScript

Address:ravnary.com/learn/java-concurrency/deadlock/neither-one-moving
A concept card on deadlock with a diagram of two threads, each holding one lock and waiting for the other, and the topic's card list beside it
A concept card with a Mermaid diagram and a warning callout.
The same card on a phone, with one progress segment per card and a bottom navigation bar
The same card at phone width.

A card is a list of blocks

Markdown, diagrams, callouts, code, tables, steps, tabs, and reveals are each a typed block. Steps, tabs, and reveals can't nest, so every step and tab has one address, like #step-2. Raw HTML is never rendered.

No loading boundary anywhere

A loading boundary would stream the response. A missing card would then answer 200 instead of 404, and with JavaScript off a card would show only its skeleton. The skeleton is drawn in the browser instead, so the server always sends complete HTML.

The answer stays on the server

The quiz page reads prompts, which are questions with the correct options, accepted answers, pairings, and explanations taken out. Only the grading step reads the record that holds the answer, and the verdict comes back after you submit.

📊 ravnary-analytics🗄️ ravnary-content⚙️ Quiz route🌐 Browser📊 ravnary-analytics🗄️ ravnary-content⚙️ Quiz route🌐 BrowserNo correct option, no explanation,no related card in the pageGET the topic quiz1Read question prompts, no answers2Questions with options shuffled by a request seed3Server action submits the draft answer4GetItem the question with its answer5Evaluate by option id, never by position6Record quiz_answered, stamped by the server7Verdict, correct answer, explanation, related card8

Options are shuffled with a seed derived from the request and the question id. Every evaluator matches on option id, so the order a reader sees never affects the grade. A numeric answer is parsed on the server, and a matching question sends both sides without the pairing.

The link to the card that teaches the question arrives with the verdict, because a card title can give the answer away.

Address:ravnary.com/learn/java-concurrency/deadlock/quiz
An ordered steps question after checking, with the verdict Not quite, the correct order, an explanation, and a link to the card that teaches it
A wrong answer still ends with the reason and the card to reread.

Analytics that cannot name a reader

Ravnary wants to know which questions are too hard or badly written. It doesn't want to know who answered them. Raw events stay private and expire after 90 days, and only aggregates ever reach a public page.

sendBeacon

conditional PutItem

20 or more sessions

fewer than 20

🌐 Browser queue
20 events · 4s idle · page hidden

🛂 /api/events
strict allowlist

📥 Raw events by type and month
pruned after 90 days

🧮 Aggregate
first attempt per session

📤 Published aggregates
metrics and ratings

🚫 Never written

👥 /community

Fields come from an allowlist

The event schema is a strict object, so email, IP address, user agent, and a client-sent correct flag are refused simply because the schema doesn't define them. The server stamps the id and the time.

The threshold applies at write time

Below 20 distinct sessions, aggregation produces no row at all. The raw figure never exists anywhere a later reader could find it, and a session is only a browser tab.

IAM draws the same line

The web role can only append to raw event partitions and can only read published ones. Even a bug in the site couldn't read another reader's events back.

Address:ravnary.com/community
Community page explaining that a question appears only after 20 separate visits have answered it, above an empty state
No question has reached 20 sessions yet, so the page is empty.

Once a question clears the threshold, the page reports its accuracy, the wrong option readers pick most, and a discrimination index. A negative index means stronger readers get the question wrong. That points to a miskeyed answer, ambiguous wording, or a question about something the cards never taught.

Your progress stays in your browser

With no accounts, there's nowhere on the server to keep your progress. Reading progress, favourites, your last place, and the review schedule live in versioned browser storage keys.

They hold ids and scheduling state, never answers or card text. The site discards any stored value in a shape it doesn't recognize, and it reads an older version once before removing it.

answered wrong

due date reached

wrong again, back tomorrow with a lower ease

right, the interval grows

due date reached

next interval would pass 21 days

Queued

Due

Scheduled

Graduated

The outcome

A library that prerenders its pages and grades every answer on the server.

Each rebuild renders the site from one published generation. Nobody needs an account, progress stays in the browser, and the reader data the site keeps is anonymous, expires after 90 days, and goes public only in aggregate.

Visit ravnary.com