How FreeAcademy.ai Scaled to 171+ Countries in 3 Months

Three months after launching FreeAcademy.ai, I opened my analytics dashboard and stared at the screen. The world map was lit up — 171 countries. Not 17, not 71 — one hundred and seventy-one. Someone in Mongolia was learning SQL. A group of students in Nigeria was working through Python basics. Developers in Vietnam, Brazil, and Egypt were completing JavaScript courses at 2 AM their local time.
I didn't plan for this. I didn't set up regional servers or implement a CDN strategy. I just wanted courses to be free and accessible. But the architecture I chose — almost by accident — turned out to be perfectly suited for global scale.
Here's how it happened.
The Stack
FreeAcademy runs on a deliberately boring stack:
- Next.js 16 with App Router and Turbopack
- Supabase (PostgreSQL + Auth + Row-Level Security)
- Vercel for hosting and edge delivery
- TypeScript everywhere
It's also part of a five-app Turborepo monorepo that includes my portfolio, a fitness certification platform, an AI consulting site, and an AI news site. Shared tooling, shared conventions, one npm run dev to rule them all.
Nothing exotic. No Kubernetes, no microservices, no message queues. That simplicity is the point.
Static Generation + ISR = Free Global CDN
The single biggest reason FreeAcademy scales is that most of it is static HTML.
Over 100 courses — with all their modules, lessons, and topics — are pre-rendered at build time. When a student in Jakarta loads a course page, they're not waiting for a server to query a database and render HTML. They're getting a cached file from the nearest Vercel edge node, often within milliseconds.
I use three tiers of Incremental Static Regeneration (ISR):
Homepage: revalidate = 3600 (1 hour)
Blog pages: revalidate = 60 (1 minute)
Popular courses: revalidate = 86400 (24 hours, via unstable_cache)
This means content stays fresh without full rebuilds. When I publish a new blog post, it appears within 60 seconds. When I add a course, the homepage picks it up within an hour. But the vast majority of pages — the actual learning content — are fully static.
The key insight: static pages + client-side auth = fast everywhere with zero cold starts. The page loads instantly from the edge. Authentication and progress tracking happen client-side after hydration. The student sees content before the auth check even completes.
Browser-Based Code Playgrounds (The Big Win)
Here's the problem: FreeAcademy has interactive coding exercises across SQL, Python, JavaScript, TypeScript, React, CSS, and more. Students in 171 countries with wildly varying network speeds need to write and run real code.
The traditional approach would be a server-side execution engine — spin up containers, manage compute, scale horizontally. For a free platform with no revenue? That's a non-starter.
Instead, all code execution happens in the browser.
I wrote a deep dive on the playground architecture if you want the full technical details. Here's the summary:
| Language | Engine | How It Works |
|---|---|---|
| SQL | sql.js | SQLite compiled to WebAssembly — full database in the browser |
| Python | Pyodide | CPython interpreter compiled to WebAssembly |
| JavaScript | Native execution | Sandboxed via custom console capture |
| TypeScript | typescript compiler | In-browser transpilation and type checking |
| React/JSX | Babel standalone | Transpiled JSX rendered in sandboxed iframes |
| React (advanced) | Sandpack | Full CodeSandbox-powered environment |
Plus CodeMirror for the code editor across all playgrounds, with language-specific extensions for syntax highlighting and autocomplete.
This architecture has three massive advantages for global scale:
Zero server load for code evaluation. When 10,000 students run Python code simultaneously, my server doesn't notice. Each student's browser is doing the work.
Works on slow connections. Once the WASM binary loads (cached by the browser after the first visit), the playground is fully local. A student on a 2G connection in rural India can write and run SQL queries all day without network requests.
Computation is distributed across users' browsers. Instead of paying for server compute that scales linearly with users, I'm borrowing a few megabytes of RAM from each student's device. They have that RAM to spare. I don't have the budget for servers.
Database Design That Doesn't Break
All user data lives in a single Supabase PostgreSQL instance. No read replicas, no sharding, no fancy distributed database. Just one PostgreSQL database doing its thing.
How does that hold up with users worldwide? Mostly fine, because I designed for it.
Composite Unique Constraints
Every progress table uses multi-column unique constraints that double as indexes:
-- Course progress: one row per user per course per site UNIQUE(user_id, course_slug, site) -- Lesson progress: one row per user per lesson per course per site UNIQUE(user_id, course_slug, lesson_slug, site) -- Topic progress: five-column composite UNIQUE(user_id, course_slug, lesson_slug, topic_slug, site)
That site field is for multi-tenancy — the same schema powers multiple academies. Right now it's just 'freeacademy', but the architecture doesn't need to change when I add more.
Row-Level Security
Every table has RLS policies that enforce auth.uid() = user_id. No application-level auth checks needed. Even if my API code has a bug, PostgreSQL won't let User A see User B's progress. It's defense in depth.
The one exception: certificates. Anyone can verify a certificate by its number — that's the whole point of a public verification system.
Idempotent Upserts
Progress tracking uses ON CONFLICT ... DO UPDATE, so completing a topic twice doesn't create duplicates or errors. This matters because browsers can retry requests, users can click buttons twice, and network timeouts can cause repeat submissions. The database handles all of it gracefully.
Parallel Queries
When loading a course page with progress data, I batch all queries with Promise.all:
const [topicsResult, lessonsResult] = await Promise.all([ supabase.from('topic_progress') .select('topic_slug') .eq('completed', true) .eq('course_slug', courseSlug) .eq('user_id', userId), supabase.from('lesson_progress') .select('lesson_slug') .eq('completed', true) .eq('course_slug', courseSlug) .eq('user_id', userId), ])
Two queries in parallel instead of a waterfall. It's not rocket science, but it's the difference between 200ms and 400ms page loads — and those milliseconds compound when your users are 12,000 km from the database.
Progress Tracking and Certificates
Progress works at three levels of granularity:
- Topic — the atomic unit (a single concept within a lesson)
- Lesson — a group of topics
- Course — a group of lessons organized into modules
The formula is simple:
progress = Math.round((completedUnits / totalUnits) * 100)
Where "units" are topics (for lessons that have them) or lessons themselves (for simpler courses). The hook walks the module structure in order and calculates a resumeUrl pointing to the first incomplete unit — so students always know where they left off.
When a student hits 100%, they can generate a certificate. Each certificate gets a unique number in the format CA-{YEAR}-{COURSE_PREFIX}-{RANDOM} — something like CA-2026-SQLB-X4KP2A. The certificate includes a QR code (via qrcode.react) that links to a public verification page. Anyone — employers, schools, other students — can scan the code and confirm the certificate is real.
A database UNIQUE constraint on (user_id, course_slug, site) prevents duplicate certificates. If someone tries to generate a second one, they just get their existing certificate back.
Content Pipeline: Filesystem + Database
FreeAcademy has a hybrid content system that solves a real problem: how do you version-control course content while also enabling rapid updates?
Filesystem content lives in the repository. Each course is a folder with a course-structure.json defining the module/lesson/topic hierarchy, plus MDX files for the actual content. This is where the 101 courses live. It's git-tracked, reviewable, and deployed with the app.
Database content is written through an admin panel. Courses, modules, and lessons can be created and edited in the browser, stored in Supabase tables. This is for rapid iteration — fix a typo, add a new micro-course, update a quiz — without waiting for a build and deploy.
The unified content layer resolves the two sources with a clear rule: database takes precedence. When fetching a course, it checks the database first. If nothing's there, it falls back to the filesystem. For the course listing, it merges both sources and deduplicates by slug, with database versions winning.
const dbSlugs = new Set(formattedDbCourses.map(c => c.slug)) const uniqueFsCourses = formattedFsCourses.filter(c => !dbSlugs.has(c.slug)) const courses = [...formattedDbCourses, ...uniqueFsCourses]
Search Without a Search Server
Search is powered by Fuse.js with a pre-generated index. At build time, a script reads every course, lesson, topic, and blog post, strips the MDX/Markdown syntax, and writes a JSON index to public/search-index.json. At runtime, the client fetches this file once, initializes a Fuse instance, and all search is instant and local.
const fuseOptions = { keys: [ { name: 'title', weight: 0.4 }, { name: 'description', weight: 0.3 }, { name: 'content', weight: 0.2 }, { name: 'tags', weight: 0.1 }, ], threshold: 0.3, includeScore: true, includeMatches: true, }
No Algolia subscription. No Elasticsearch cluster. Just a JSON file and a fuzzy matching library. It's fast enough, and it costs nothing.
What I'd Do Differently
No architecture survives contact with reality without revealing some rough edges. Here's what I've learned:
I should have added database read replicas earlier. With one PostgreSQL instance serving global traffic, students far from the database region (US East) experience higher latency on authenticated requests. A read replica in Europe or Asia would cut progress-loading times significantly. Supabase supports this now — it's on my list.
The search index should be smarter. Fuse.js is great for its simplicity, but with 100+ courses the index file is getting large. I should move to a proper inverted index or consider a lightweight server-side search that returns paginated results.
I underestimated the importance of offline support. Students in areas with unreliable internet connections would benefit enormously from a service worker that caches course content. The static architecture is already 90% there — I just need to add the caching layer.
Content versioning needs work. The filesystem/database hybrid works, but there's no good story for "what did this course look like last month?" Git history covers filesystem content, but database content changes are ephemeral. I need an audit log.
The Takeaway
You don't need microservices or Kubernetes to serve 171 countries. You don't need a distributed database or a team of DevOps engineers. You don't even need a lot of money.
What you need is the right defaults:
- Static generation so your content is pre-rendered and edge-cached globally
- Client-side execution so your users' browsers do the heavy lifting
- A managed database with proper indexing and row-level security
- Incremental Static Regeneration so content stays fresh without rebuilds
FreeAcademy serves students in 171+ countries with a stack that costs less per month than most people spend on coffee. The architecture isn't clever — it's deliberately boring. And boring scales.
If you want to see it in action, check out FreeAcademy.ai. Pick a course, run some code, and see for yourself.
This is part of a series about building FreeAcademy. See also: Why I Built a Free Learning Platform, How I Built the Interactive Code Playgrounds, and Lessons from Running 4 Next.js Apps in a Monorepo.