I'm building NaHero (https://nahero.site) on my own. It's a set of timed practice exams for AWS, Azure and Google Cloud certifications, in Brazilian Portuguese and English. There are 30 exams and about 8,700 questions, and every exam exists in both languages. Each exam has 65 questions, the real exam's time limit (90 minutes for CLF, 130 for SAA) and the passing score from the official exam guide.

I started it because I was studying for an AWS certification and couldn't find anywhere to take practice exams for free.

Three parts turned out to be harder than they looked: tagging every question with its exam domain using an LLM, keeping the two languages consistent, and generating study plans without letting the model do the math.

Stack

  • Front: Next.js, Tailwind, react-query
  • Back: Spring Boot, Postgres, Flyway
  • Payments: Stripe
  • Study plan text: DeepSeek V4.1 Flash
  • License: AGPL-3.0, nahero-front and nahero-back

Why domains matter

Official exam guides split each certification into domains. A single score tells you whether you passed. A score per domain tells you where to study. NaHero shows results per official domain on the free tier, and the paid study plan is built on top of those numbers. All of that depends on every one of the 8,700 questions carrying the right domain.

The tagging loop

I tagged the questions with claude -p (Claude Code's non-interactive mode) in a loop:

  • 40 questions per call
  • the domain list for that specific exam in the prompt
  • output validated against a fixed list of domains for that exam

The whole bank took about 220 calls.

Putting the exam's own domain list in the prompt turns an open-ended question ("what is this about?") into a choice from a closed set. Validating against the same list means a domain name the model makes up, or paraphrases, never reaches the database.

The consistency problem

Every question exists twice, once in Portuguese and once in English. That gave me a check I didn't need labeled data for: tag both versions independently and see whether they get the same domain.

On Google Workspace Admin, the first pass agreed about 73% of the time.

For the product, that's a bug. If roughly a quarter of questions land in a different domain depending on language, a Brazilian user and an English-speaking user with identical answers get different per-domain results, and per-domain results are what the whole product is built around.

Two changes fixed it:

  1. Per-exam disambiguation lines in the prompt such as "billing and pricing".
  2. Tag English only, then copy the domain to Portuguese. With one source of truth, the two versions agree by construction.

The second change is the less clever one and the one I'd recommend first. The first change still matters, because agreement alone doesn't make the English tag correct.

If you have equivalent inputs (two languages, two phrasings, two orderings), agreement between them is a cheap way to find errors when you have no ground truth.

Study plans: SQL does the math, the LLM writes the text

Premium users get a study plan after each attempt: 3 priorities and an ordered list of topics. The pipeline:

  1. SQL computes hits per domain, missed questions and previous scores.
  2. DeepSeek V4.1 Flash receives those numbers and returns JSON with the text. It doesn't calculate anything.
  3. The output is validated: domain names in the response must match the ones in the input, and links aren't allowed.
  4. The plan is stored once per attempt, so reloading the result page doesn't regenerate it.
  5. Failures are stored too, so the system doesn't keep retrying the same attempt forever.

Matching domain names means the plan can't tell you to study a domain that isn't on your exam. Banning links means it can't send you to a URL nobody checked. Storing once means the plan you read today is the one you read tomorrow. Storing failures keeps a bad attempt from turning into a loop of paid API calls.

Resuming an attempt

A 130-minute exam doesn't always fit in one sitting. Answers are auto-saved as drafts, and reopening an attempt puts you back on the last question. Resume is on the free tier.

What the data says so far

Numbers as of October 6, 2026:

  • About 95 registered users, about 75% from Brazil.
  • About 30 signups a week, with no ads running.
  • Most CLF attempts score between 23 and 47 out of 65. Passing is around 46, so most attempts fail.
  • Only about 15% of users took a second exam in their first week.

Those last two together changed the product. Someone takes an exam, fails, sees a number and doesn't know what to do next. So I rebuilt the result screen around "what to study next".

On acquisition: I tested Google Ads with R$100. That got 71 clicks at R$0.31 to R$0.90 per click, an 11% click-to-signup rate and 1 annual subscriber. LinkedIn posts bring in 3 times what Google Ads did.

The first paying user came from an ad, left, and came back after the 7-day re-engagement email. They scored 44 out of 65, subscribed 36 minutes after seeing the failing score, and passed with 55 out of 65 three days later.

Pricing

All 30 exams, unlimited attempts, scores, per-domain results and resume are free. Premium is R$10/month or R$90/year and adds explanations per question, the study plan and practice questions on your weakest domain.

What I'd like feedback on

If you've used LLMs for classification across languages, I'd like to know how you measured consistency and what you did about disagreements. The code is open, so you can also read exactly how it's done in the repos linked above.