How I Built a Modern Course Platform in 2024

I've spent the past 15 months building a new course platform for Flutter developers, now live at pro.codewithandrea.com.

It's more than just a site—it's a robust multi-page application (MPA) with a bunch of complex features, including:

  • user authentication and account management
  • checkout flows
  • course enrolment and progress tracking
  • lesson UI with custom components
  • admin dashboard

In this article, I reveal the technologies I’ve used, some of the decisions and challenges I faced, and share insights from shipping a project like this. I’ll also talk about SSR, edge computing, DB migrations, and even share a breakdown of my running and outsourcing costs.

This article is mainly inspired by “How I built a modern website in 2021” by Kent C. Dodds, a world-class developer and educator who I admire and respect. While my article won’t go in as much depth as his, I hope you’ll still find it useful.

With that said, let’s dive in!

Stats and Project Size

As of February 2024, these are the cloc stats:

cloc src/ public/ supabase/ github.com/AlDanial/cloc v 2.00 T=0.26 s (3657.8 files/s, 209243.6 lines/s) -------------------------------------------------------------------- Language files blank comment code -------------------------------------------------------------------- Markdown 751 8708 0 27638 Astro 129 898 379 6839 TypeScript 47 754 935 4076 HTML 9 124 273 3425 SQL 16 156 0 384 CSS 2 41 8 143 SVG 6 0 0 131 TOML 1 16 59 66 JSON 1 0 0 19 JavaScript 1 3 0 13 -------------------------------------------------------------------- SUM: 963 10700 1654 42734 --------------------------------------------------------------------

As you can already tell, the project was written with Astro, and most of the code consists of over 10K lines of Astro and TypeScript.

Beyond the core, I also have a CLI tool written in JavaScript, which was used to migrate all my students from Teachable to the new platform:

cloc cli/bin/ github.com/AlDanial/cloc v 2.00 T=0.01 s (1186.8 files/s, 151237.7 lines/s) -------------------------------------------------------------------- Language files blank comment code -------------------------------------------------------------------- JavaScript 12 185 84 943 HTML 1 18 39 512 JSON 1 0 0 3 -------------------------------------------------------------------- SUM: 14 203 123 1458 --------------------------------------------------------------------

Here’s a chart showing all the contributions to date:

GitHub contributions from November 2022 to February 2024
GitHub contributions from November 2022 to February 2024

My web developer and I made 294 commits to the dev branch (counted with git rev-list --count dev), closing 212 PRs to date.

And this brings me to the next point.

I have not built this project alone

From the very beginning, I had some good reasons to outsource design and development:

  • Creating content is my main focus
  • Web development isn’t my strong suit
  • I had the budget for it

I hired a strong and very reliable web developer from the start and established a clear development process where:

  • I write a specification for each complex feature (epic) and break it into smaller stories
  • We discuss most of the technical details and tradeoffs upfront
  • My developer writes the code and opens a PR
  • We test and review the PR
Internal GitHub project board for Code With Andrea Pro
Internal GitHub project board for Code With Andrea Pro

While I wrote very little code, I was heavily involved the analysis, specification, and review of each feature.

Following this process, we worked together for 15 months and shipped a high-quality product, with very little waste, while only communicating asynchronously via chat and GitHub.

As they say, you can only choose two out of three:

  1. Quality: build a quality product
  2. Cost: within budget
  3. Speed: on time

I decided to focus on quality over speed and ship a Minimum Lovable Product (MLP). I think it’s a great way to build software.

Now, let's peel back the layers of my tech stack. 👇

Astro, Supabase, and all the other things

Simply put, Astro is amazing. It is the only framework that won my heart since discovering Flutter in 2018.

I’ll sing all my praises about Astro in a minute. 😍

But first, let’s make something clear.

Flutter Web (not)

You all know the saying, right?

  • Flutter web is for web apps, not websites

Indeed, I knew that Flutter web was not the right tool for this platform, and our technical requirements made this even clearer:

  • Multi-page application rendered via SSR
  • Nice MDX authoring experience with custom JSX components
  • Simple, filesystem-based routing mechanism with easy redirects
  • Fast page loads
  • SEO-friendly

While Flutter web was not the right tool, I wasn’t too keen on others either.

Next.JS and React (not)

React is extremely popular, but many devs have been experiencing “React fatigue” for years, and I didn’t want to join the club.

And since Next.JS is built on top of React, that was a no-go, either.

Firebase (not)

I seriously considered Firebase, as I’m quite good with it.

Initially, my database needs were unclear, and I couldn’t foresee all the details of the features we needed to build.

But one question kept bugging me: “What if I need SQL and relational data in the future”?

So I decided to play it safe and choose Supabase, with its trusty PostgreSQL (more on this below).

Enough said about the tech I didn’t use. Let’s look at my actual tech stack. 👇

Astro

For me, Astro was love at first sight.

Where else can you find a tool that is optimized for content websites, supports SSR and API endpoints, ships zero JS by default, has a ton of integrations, is extremely well-documented and customizable, and is stupidly easy to use?

The “stupidly easy to use” part was perhaps the most appealing.

Take the Astro code for the waitlist page, for example:

--- import Layout from "@layouts/Layout.astro"; import Header from "@components/Header.astro"; import Footer from "@components/Footer.astro"; import { WAITLIST_ENABLED } from "@scripts/shared/constants"; import { getUser } from "@scripts/server/session"; import Reviews from "@components/waitlist/Reviews.astro"; import JoinWaitlistForm from "@components/waitlist/JoinWaitlistForm.astro"; import AboutMe from "@components/waitlist/AboutMe.astro"; import WaitlistEmailLinkDialog from "@components/waitlist/WaitlistEmailLinkDialog.astro"; if (!WAITLIST_ENABLED) { return Astro.redirect("/"); } const user = await getUser(Astro.request, Astro.response); if (user) { return Astro.redirect("/"); } --- <Layout> <Header user={null} /> <JoinWaitlistForm /> <Reviews /> <AboutMe /> <WaitlistEmailLinkDialog /> <Footer slot="footer" /> </Layout>

It's a no-brainer: SSR logic up top, layout below.

Even if you don’t know TypeScript, you can guess what this code does:

  • If the WAITLIST_ENABLED flag is false or the user object exists, redirect all requests to the root page (”/”)
  • Otherwise, render the layout defined at the bottom

This is one of the simplest pages on the site, but I also have complex pages with more conditional logic and multiple async calls that fetch data from the DB.

Yet, the programming model remains fundamentally the same, and I can easily follow the code, line by line.

Before I talk about my backend, I think TailwindCSS deserves a mention.

TailwindCSS: It Just Works

Honestly, I didn’t have to choose TailwindCSS and could have gone for other options.

But I did want to avoid any debates over CSS class naming (which would have wasted time during code reviews).

Using Tailwind meant that most of our styling code looks like this:

<figure class="relative mb-5 h-32 w-32 lg:mb-0"> <div class="h-full w-full overflow-hidden rounded-full"> <img class="block h-full w-full object-cover" src={avatarSrc} alt="" role="banner" /> </div> <label class="group absolute bottom-0 right-0 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-white shadow-edit-avatar transition-colors duration-300 hover:bg-neutral-500" > <IconEdit class="text-neutral-800 transition-all duration-300 group-hover:text-white" /> <input id="upload-avatar" accept={ALLOWED_AVATAR_MIME_TYPES.join(",")} class="absolute left-0 top-0 h-full w-full cursor-pointer text-[400%] opacity-0" type="file" name="avatar" /> </label> </figure>

If I’m honest, I can’t read this code and tell you what all those utility classes do.

But I don't need to. My developer takes the design, turns it into code, and if it looks sharp on screen, that's a win in my book. 😃

TypeScript

I like my languages to be type-safe and not get in my way.

When used appropriately, TypeScript fits that bill.

And yes, custom TypeScript types can get quite unwieldy and at times, we ended up with stuff like this:

type CourseFile = Awaited<ReturnType<InstanceType<typeof CoursesDataset>["getCourses"]>>[number];

But slowly, I learned to live with it, and for the most part our TypeScript code has remained fairly readable.

Given our tech stack, the only alternative would have been JavaScript, and I’m glad we didn’t go there. 😌

Supabase

Supabase is great!

As I said before, I felt I needed a SQL database, and Supabase delivers that with PostgreSQL.

But it brings many other benefits too:

  • Great documentation and community
  • Database migrations that “just work” (at least for the simple use cases we faced)
  • Nice client SDK
  • AI-powered SQL editor (I really love this feature)
  • Great customer support (only needed it twice, but they were very responsive and helpful)

Supabase Type Generation

We didn’t use any fancy ORMs like Prisma, nor did we write any abstraction layers (KISS!).

Instead, we used the Supabase CLI to generate types from the database schema.

This meant that in our client code, we could write helper methods like this:

When querying data with the Supabase client SDK, the returned data is type-safe
When querying data with the Supabase client SDK, the returned data is type-safe

Quite handy, with all the right types inferred automatically!

Automatic Supabase DB Migrations

Supabase supports database migrations, a great way of tracking changes to the DB schema over time.

Whenever we work on a feature that requires some schema changes, a migration file is generated:

Example of a Supabase migration file
Example of a Supabase migration file

Then, when we deploy our changes, we can include a Vercel build command that performs the migration and updates the production database (only if the deployment is successful).

Coming from Firebase, this feels like a great quality-of-life improvement!

SQL Queries

For the first six months, we worked on features that didn’t require any complex relationships between tables. And for that, any NoSQL database would have suited us just fine.

But eventually, we created an admin dashboard that required more complex queries, and that’s when the power of SQL made our life much easier.

Truth be told - I’m not a SQL expert. But Supabase's SQL Editor has an AI feature that works like magic. I describe my query in plain English, and it generates the correct SQL query for me:

Supabase SQL Editor with AI capabilities
Supabase SQL Editor with AI capabilities

This is super handy and a great way to get useful insights from the DB whenever I need them.

Overall, I’m very happy with Supabase, and I’ll continue using it for many years on my platform.

Vercel

These days, there are many hosting providers to choose from.

I chose Vercel because I was already familiar with it, and it is well-supported by Astro (in fact, it even became Astro’s official hosting partner last year).

I’m mostly happy with it, and my only pet peeve is that the logs view only retains data for a maximum of 24 hours. So, I signed up for an external monitoring tool that retains data for up to 30 days.

Server Side Rendering

My course platform is not a simple site where all the pages can be statically generated at build time.

For example, when a course lesson is requested, we need to run some checks:

  • Is the user authenticated?
  • Is she enrolled in the course?

Based on this logic, we either return the HTML with the lesson content or a “content locked” page that looks like this:

Content locked page on Code With Andrea Pro
Content locked page on Code With Andrea Pro

Astro supports this with server-side rendering (SSR). However, there are some implications when it comes to latency.

Living on the Edge

Static sites are fast because the pages are pre-built and stored on a CDN near the user.

With an SSR app on the edge, we can reap the same benefits and minimize latency for all page loads.

But it gets tricky when the SSR logic depends on database reads or writes. We faced a choice, as detailed in Vercel's runtime documentation:

  • Node.js runtime: We could pick a Vercel region close to our Supabase server, but distant users might experience lag.
  • Edge runtime: Alternatively, edge functions run nearer to the user yet farther from the DB, potentially increasing DB-related delays.

After trying the Node.js approach, we noticed sluggish page loads—some up to 10 seconds—likely due to cold starts. Switching to edge functions gave us a significant speed boost, but we still pay the “latency tax” when making DB calls in our SSR code.

In December 2023, Supabase introduced read replicas along with Fly Postgres, which would allow us to serve data closer to our users. At the time of writing, the platform still runs on Vercel edge functions with a single DB instance, but we’re eager to experiment with read replicas once they go out of beta.

Cloudflare Stream

With over 40 hours of video content, I needed a reliable yet cost-effective streaming service.

It was a close call between Cloudflare Stream and Mux, but Cloudflare Stream won. Here are some things I liked about it:

During beta testing, some students said they wished the player UI was more customizable. In retrospect, Mux may have been a better choice, but I’m not planning to switch for now.

LemonSqueezy

My options for payment processing were Stripe, Paddle, and LemonSqueezy.

Ultimately, I chose LemonSqueezy for these reasons:

  • Merchant of Record (MoR) with global tax compliance
  • Great docs and super easy to integrate
  • Beautiful and robust checkout experience
  • Good customer support

The downside is that based on this SaaS Fees Calculator, their fees are a bit high, especially for international payments. But for now, this is an acceptable tradeoff.

Other Services

In addition to the services above, we also use:

  • MailerSend for transactional emails → generous free plan, excellent uptime, nice dashboard, configurable templates
  • Doppio for certificate generation → good price, simple API, can generate PDF files asynchronously without slowing down other parts of our backend
  • Axiom for logs and monitoring → free with 30-day log retention, though I don’t like the UI very much
  • Plausible for privacy-friendly analytics → excellent product, I’ve been using it for my main site for 3+ years already
  • Paperform for collecting student feedback → good form builder with integrations and webhooks support; I will drop this once I have a custom feedback system

Running Costs

As I write this, these are the estimated monthly costs for the services I’m using:

  • Cloudflare Stream: $50 (storage + streaming)
  • Vercel: $40 (2 seats)
  • Supabase: $35
  • Doppio: $16
  • GitHub: $12 (3 seats)
  • Axiom: free with 30-day log retention
  • Mailersend: free up 12K emails
  • Paperform: $49
  • Plausible: $12 (shared with my main site)

Total: $214 / month.

This covers the running costs alone, though I expect to pay more than that on LemonSqueezy transaction, platform, and payout fees.

I also pay $30/month for Figma (two seats) and $2000/year for ConvertKit - though these are external services that are not needed for running the platform.

Outsourcing Costs

Over the past 15 months, I spent over $50K in design and development services, plus 256 hours as the project owner.

It's a big investment, but the return is a tailored platform that delivers a much better learning experience for my students.

Indeed, I’ve already received a lot of positive feedback, and I can’t wait to ship new exciting features in the future!

Other Challenges: Authentication is Hard

While I’m very happy with the chosen tech stack, shipping this project wasn’t without challenges.

One of the biggest issues we faced was with email link authentication.

This was fairly straightforward to implement, some users were hitting a brick wall with an “Email link is invalid or has expired" error and could not login at all! 😱

Of course, “it works on my machine” is not a good way to do customer support, and we spent a bunch of time digging into the error logs and scratching our heads.

As it turns out, some email providers prefetch URL links from incoming emails for safety (I’m looking at you, Microsoft Defender 😠), and this invalidated our login links before users could click on them.

As a workaround, we followed the Supabase documentation and added an extra redirect step, ensuring the email link is only processed when the user clicks a button on our site.

Looking back at the VAST Stack

When we started this project, the conventional wisdom would have been to choose Next.JS & React.

But we took a leap with Astro, and I’m very happy we did.

Hence, we have a cool name for our tech stack: VAST (Vercel, Astro, Supabase, TypeScript/TailwindCSS).

These tools served us very well for this project, and hopefully, they will continue to do so in the years to come.

Conclusion and Plans for the Future

Building this course platform has been a great learning experience and a welcome change in scenery after spending the last 6 years with Flutter.

There’s so much more I want to do with it, including performance tuning, quality-of-life improvements, and new features.

In fact, I’m nearly ready to ship a new “learning mode” that didn’t quite make it for launch (more details soon).

For now, I hope you enjoyed this technical breakdown and that you’ll enjoy learning on Code With Andrea Pro even more!

Happy coding!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.