Build journal
ARCHITECTUREArchitecture 4 min read

The Tech Stack

Published Jul 4, 2026

A modern, boring-in-the-best-way stack chosen for durability and clarity, not novelty.

Every learning platform is really three hard systems wearing a trench coat: a content pipeline that calls flaky third-party LLMs, a durable job engine that must survive crashes mid-generation, and a reactive UI that streams tokens as they arrive. The temptation is to reach for something novel for each. Praxis924 does the opposite — it picks boring, proven tools and lets the interesting behavior emerge from how they fit together. Here is the stack, and why each piece earns its place.

The problem

The naive version of "AI generates a lesson" is a single request handler that loops: build a prompt, call the model, save the result. It works in a demo and falls apart in production. LLM providers rate-limit you (HF 402, Gemini 429 limit:0, Groq 429 on tokens-per-minute), time out, or return junk. A lesson is not one call — it is four sections, each with a review-and-improve loop, then exercises, then citations. If the process crashes on section three, you either lose everything or, worse, persist a half-generated lesson and charge a real learner to read it. Meanwhile a vanilla request blocks for minutes, and the browser has no idea whether anything is happening.

So the stack has to answer three questions: How do we survive provider failure? How do we survive our own crashes? How does the user see progress?

The design

Backend: FastAPI + SQLAlchemy + PostgreSQL/pgvector. FastAPI gives typed, async request handling; Pydantic validates every IO boundary; SQLAlchemy maps the domain (Technology → Framework → ContentItem). Postgres is the single source of truth, and the pgvector extension means RAG embeddings live in the same database as lessons and progress — no separate vector store to keep in sync. One backup, one consistency model.

DBOS for durable generation. This is the load-bearing choice. Lesson generation is a DBOS workflow whose steps are checkpointed to Postgres. Crash after section two and the workflow resumes from section three on restart — no Redis queue, no re-running expensive completed steps.

python
1@DBOS.workflow()
2def generate_lesson(item_id: str):
3    source = build_grounded_source(item_id)      # top-K pgvector chunks, capped ~3000 chars
4    for name in ("main_explanation", "practical_explanation",
5                 "key_principles", "summary"):
6        section = generate_section(name, source)  # each a checkpointed step
7        for _ in range(2):                         # review loop
8            if score(section) >= 7: break
9            section = improve(section)
10        persist(item_id, name, section)
11    generate_exercises(item_id, source)
12    set_status(item_id, "generated")              # pending→generating→generated
Powered by AI

One LLMService singleton with provider failover. Priority is NVIDIA → Groq → Gemini → Hugging Face; the first configured key wins and calls fail over on quota or outage. Embeddings always use Hugging Face at EMBED_DIM=384 so vectors stay comparable regardless of the chat provider. Redis caches LLM responses so identical prompts don't re-bill. Raising LLMUnavailableError leaves the lesson re-runnable rather than persisting placeholder junk.

Frontend: Next.js 15 (App Router) + React 19 + TypeScript + Tailwind + Zustand. Server components for the reading-heavy /learn viewer, client components for anything interactive. Zustand holds auth (token + role) read synchronously from localStorage. Token streaming — for Lucy and generation status — goes over SSE through a dedicated services/stream.ts, not Axios, because fetch + ReadableStream is the right tool for a one-way token feed.

Docker Compose everywhere. The same four-service stack (db / redis / backend / frontend) runs on a laptop and on the production EC2 box. "Works on my machine" becomes "works, because it is the machine."

The gotchas

Every one of these cost real debugging time and now lives in the operating manual.

TrapFix
Groq sits behind CloudflareSend a User-Agent header or get HTTP 403 (error 1010)
Grounded source re-sent every promptCap it ~3000 chars to protect tokens-per-minute
New table vs. altered tableNew tables auto-create via create_all; altering existing tables needs an Alembic migration
Provider quota mid-generationRaise LLMUnavailableError so the lesson stays re-runnable
Nginx reverse proxyproxy_pass with a trailing slash rewrites /api/v1/* and every call 404s

Warning: The Nginx trailing-slash trap is the sharpest. proxy_pass http://127.0.0.1:8000;no trailing slash. Add one and /api/v1/* silently becomes /v1/*, 404-ing every API call. It lives on the host, not in a container, so a server rebuild can quietly reintroduce it.

Build it yourself

The minimal version fits in an afternoon:

bash
1# Backend
2uv add fastapi sqlalchemy psycopg pgvector dbos redis
3# Frontend
4npx create-next-app@latest --typescript --tailwind
Powered by AI

Stand up Postgres with pgvector/pgvector:pg16, create the vector extension in your FastAPI lifespan before create_all, wrap one generation function in a DBOS workflow, and put an LLM call behind a service class with a try-next-provider loop. That is a working durable AI pipeline. Everything else is refinement.

What changes at scale

The design stays; the topology grows. Postgres and pgvector split into a managed primary with read replicas, and if vector volume outgrows it, embeddings move to a dedicated store. The single EC2 box becomes an autoscaling group behind a load balancer, with the manual AWS-console provisioning finally replaced by real IaC. DBOS already scales horizontally on Postgres, so more generation throughput is more workers, not a rewrite. And Redis graduates from a response cache to a shared session and rate-limiting layer. The bet is that a boring stack composes cleanly under load — and so far it has.

Published in build journal