Module 7 of 9 Full stack
The Freemium Indie Stack, Applied
Why this matters for Univa
This module is not theory. It is Univa's own toolbox, named and mapped. univa.my deploys to Cloudflare Pages via GitHub Actions and wrangler. Client apps run on Next.js, Supabase, and Vercel per docs/app-building-philosophy.md. Some client work already uses ToyyibPay for Malaysian payments with DuitNow QR. The point of this module is to turn "the stack that already works" into "the stack Ahnaf can explain, defend, and extend on purpose," including the pieces not yet in daily use: Resend, CHIP, shadcn/ui as a deliberate choice rather than a default, and a clear answer for crash/error reporting on the web (Firebase Crashlytics is not it).
Knowing this stack cold also pays off commercially: SME clients ask "why this tool and not that one," and "it's free and it works" is a weaker answer than "here is what each layer does, here is what it costs at your scale, and here is when we would need to change it."
Core concepts
The stack as a set of layers, not a single tool
A freemium indie stack is not one platform doing everything. It is a set of specialized layers, each owned by a different vendor, wired together by the developer:
| Layer | Tool | What it owns |
|---|---|---|
| IDE | VS Code | Where the code gets written, locally |
| Version control + CI | GitHub (+ Actions) | Source of truth for code; runs tests/builds/deploys on every push |
| Static hosting, edge functions, storage, DNS | Cloudflare | Serves the site fast worldwide, runs light edge logic, owns the domain |
| Deployment | Vercel | Builds and hosts Next.js apps, one git push away from live |
| Auth + database | Supabase | Manages who a user is and what data they can see |
| Transactional email | Resend | Sends the emails an app triggers (welcome, receipt, reset password) |
| UI components | shadcn/ui | Pre-built, ownable React components, not a hosted service |
| Payments | Stripe / CHIP / ToyyibPay | Takes money from a customer and tells the app it happened |
| Design | Figma | Where the interface gets designed before it gets built |
| Crash/error reporting | Firebase Crashlytics (mobile) / Sentry (web) | Tells the team when something broke, and for whom |
Two deploy targets, one decision
Cloudflare and Vercel both host applications, and Univa genuinely uses both, for different reasons:
- Cloudflare Pages hosts univa.my: a marketing/brochure site plus light dynamic logic in Workers. Cloudflare wins here on cost (generous free tier, zero R2 egress) and edge reach.
- Vercel hosts client Next.js apps, because Vercel is built specifically around Next.js: zero-config builds, preview deployments per pull request, and first-class support for the framework's server components and API routes.
The decision rule in practice: Next.js app with real backend logic and a team that will grow it → Vercel. Static or near-static site with simple edge logic → Cloudflare Pages. Do not run the same app on both; pick one deploy target per project and let the other layers (Supabase, Resend, payments) plug into whichever one was chosen.
shadcn/ui is not a hosted service
Every other tool in this list is a company running infrastructure Univa depends on. shadcn/ui is different: it is a CLI that copies component source code (buttons, forms, dialogs, tables) directly into the project's own codebase, built on Tailwind and Radix primitives. There is no shadcn server to go down, no shadcn free tier to exceed, and no shadcn bill. The trade-off is that updates are manual (there is no package to npm update), and Univa owns and maintains every component it pulls in. This fits the freemium philosophy precisely: no recurring cost, but also no external dependency in production.
Auth, RLS, and Realtime in one box
Supabase bundles three things Univa would otherwise stitch together from separate vendors:
- Auth: sign-up, login, magic links, OAuth providers, session/JWT management.
- Postgres with Row Level Security (RLS): a real relational database where access control is enforced inside the database itself, as SQL policies, not just in application code. A user's JWT carries their identity into every query; a policy like "users can only see their own rows" runs on every request whether or not the application code remembers to check.
- Realtime: subscribe to database changes over a websocket, so a UI updates live when data changes, without polling.
The RLS point matters enough to repeat: application-level access checks can be forgotten in one code path and enforced in another. RLS policies live with the data, so they apply everywhere, including a client hitting the database directly.
Payment gateways: three different shapes of the same job
All three gateways covered here do the same core job (take a customer's money, tell the app it happened), but they differ in market, fee shape, and integration effort:
- Stripe is a global, developer-first gateway. Extremely polished SDKs and docs, but its Malaysian rates (3% + RM1 domestic cards/FPX, more for international cards and currency conversion) are the highest of the three for a purely local SME transaction.
- CHIP is a Malaysia-based gateway with no setup or monthly fee, transparent per-method pricing, and native support for FPX, DuitNow QR, cards, and local e-wallets in one integration.
- ToyyibPay is the gateway Univa already uses in client work. It charges a flat fee per transaction (not a percentage) for FPX, which makes it unusually cheap for small-ticket SME transactions, and supports DuitNow QR through specific API flags (
enableDuitNowQR,chargeDuitNowQR) plus an activation step, perreference_toyyibpay_duitnow_qrin memory.
The right choice depends on transaction size and payment mix, covered in the comparison table below.
Crash reporting: Firebase Crashlytics is a mobile tool wearing a general name
Firebase Crashlytics is genuinely excellent, but it was built for native iOS and Android apps: it hooks into the mobile crash reporting APIs (symbolicated native stack traces, ANRs, non-fatal mobile exceptions). It is not the right tool for a Next.js web app. For web, Sentry is the equivalent: it captures unhandled JavaScript exceptions, API route errors, performance traces, and session replay, all in the browser and Node.js runtime context a web app actually runs in. Univa's rule going forward: Crashlytics only if shipping a native mobile app (React Native, Flutter); Sentry for every web app, including every client Next.js build.
Domains and DNS: one owner, many pointers
A domain has exactly one home for its DNS records, and Univa keeps that home on Cloudflare. univa.my's DNS lives in Cloudflare, which then points different subdomains and record types at whichever service actually serves that traffic:
- The root domain and static site point at Cloudflare Pages itself.
- Email for the domain routes through Cloudflare Email Routing, splitting mail to the right inboxes (per
reference_univa_site_deploy_pipeline:ahnaf@,admin@,shafiq@, and a catch-all). - If a client project is deployed on Vercel instead, the domain's DNS still lives wherever it was registered (often Cloudflare), but a CNAME or A record is pointed at Vercel's servers for that specific subdomain or domain.
The concept to hold onto: registration and DNS management is one job, hosting is a different job, and a single domain can point different parts of itself (root site, a subdomain, email) at entirely different providers at the same time.
Secrets and environment variables across layers
Every layer in this stack needs its own credentials, and none of them should ever live in the codebase itself:
- Supabase issues a public anon key (safe in client-side code, restricted by RLS) and a private service-role key (server-only, bypasses RLS, never shipped to the browser).
- Resend, Stripe, CHIP, ToyyibPay each issue an API key or secret that authorizes the app to send email or move money on the account's behalf.
- Sentry issues a DSN (a semi-public identifier for where to send error reports) plus an auth token for CI-time features like source map uploads.
The rule Univa already follows for its own tools applies here without exception: credentials live in .env (gitignored) locally, and in the platform's own environment variable dashboard (Vercel project settings, Cloudflare Pages environment variables, GitHub Actions repository secrets) in production. Nothing that can move money, send email, or write to a database as an admin belongs in a client-side bundle or a committed file.
A quick glossary
- CDN (Content Delivery Network): a network of servers, like Cloudflare's, that caches and serves static content from the location closest to each visitor.
- CI (Continuous Integration): automatically building, testing, and (optionally) deploying code every time it is pushed, so broken code is caught before it reaches production.
- JWT (JSON Web Token): a signed token that proves who a user is, passed with each request so a backend or database can check identity without a separate login step every time.
- Webhook: an HTTP callback a third-party service (a payment gateway, for example) makes to the app's own server when something happens on their end, like a completed payment.
- DSN: the endpoint identifier an error-reporting SDK (Sentry) uses to know where to send captured errors.
- CNAME record: a DNS record that points one domain or subdomain at another hostname, the mechanism used to hand a subdomain off to a hosting provider like Vercel.
The landscape (comparison tables)
Full stack, layer by layer
| Layer | Tool | Free tier headline | Alternative worth knowing |
|---|---|---|---|
| IDE | VS Code | Free, forever | JetBrains, Cursor |
| Version control + CI | GitHub + Actions | 2,000 CI minutes/month private repos, unlimited public | GitLab |
| Static hosting / edge / storage / DNS | Cloudflare | Generous free Pages + Workers + R2 | AWS (CloudFront + S3 + Route 53) |
| Deployment (Next.js) | Vercel | ~100k invocations/month, non-commercial | Netlify, Cloudflare Pages |
| Auth + DB | Supabase | 500MB DB, 50k MAU | Firebase (NoSQL, different model), PlanetScale |
| Transactional email | Resend | 3,000 emails/month (100/day cap) | Postmark, SendGrid |
| UI components | shadcn/ui | Free, source-owned | Material UI, Chakra |
| Payments | Stripe / CHIP / ToyyibPay | See payments table below | Billplz, Curlec |
| Design | Figma | 3 files (Starter) | Penpot (open source) |
| Crash/error reporting | Sentry (web) | 5,000 errors/month, 1 user | LogRocket, Bugsnag |
Payment gateways compared (Malaysia)
| Gateway | Setup/monthly fee | FPX fee | DuitNow QR | Cards | Notes |
|---|---|---|---|---|---|
| Stripe | None | 3% + RM1 (domestic) | Not natively (needs a bridge) | 3% + RM1 domestic, +1% international, +2% FX | Best global SDK/DX; most expensive for a pure local FPX/DuitNow flow |
| CHIP | None | RM1 (B2C) / RM2 (B2B) flat | 1% (min RM0.15) | 2% local credit / 1% local debit / 3% foreign | All local rails in one API; fees subject to 8% SST |
| ToyyibPay | RM100/year from year 2, no setup fee | RM1 (B2C) / RM2 (B2B) flat | Supported via enableDuitNowQR + chargeDuitNowQR + activation | Limited/varies by plan | Cheapest for small-ticket local FPX; Univa's current default |
Note: gateway fees change; verify current rates on the provider's pricing page before quoting a client.
Crash/error reporting: Crashlytics vs Sentry
| Firebase Crashlytics | Sentry | |
|---|---|---|
| Primary target | Native mobile (iOS/Android) | Web (JS/TS, Node.js), plus mobile SDKs |
| What it captures | Native crashes, ANRs, non-fatal exceptions | Unhandled exceptions, API errors, performance traces, session replay |
| Free tier | Free forever (Spark plan) | 5,000 errors/month, 1 user |
| Univa's use | Only if shipping a native mobile app | Default for every web app, including client Next.js builds |
A request's journey
Trace one real action end to end: a customer on a Malaysian SME's site signs up, then makes a purchase.
- DNS and first byte. The browser resolves the domain through Cloudflare's DNS, then Cloudflare's edge network (or Vercel's CDN, depending on which deploy target the app uses) serves the static shell and assets from a point of presence near the user.
- The UI renders. The page is built from shadcn/ui components, styled with Tailwind, code that lives in the app's own repository, not fetched from a third party at runtime.
- Sign-up. The user submits the sign-up form. The client calls Supabase Auth, which creates the user record and issues a JWT session token.
- Data access is enforced. Every subsequent database read or write carries that JWT. Row Level Security policies inside Postgres decide what this specific user is allowed to see or change, independent of whatever the frontend code does or forgets to check.
- Checkout. The user buys something. The app calls ToyyibPay (or CHIP, or Stripe) to create a payment bill/session and redirects the user to complete payment (FPX, DuitNow QR, or card).
- The webhook. Once payment completes, the gateway calls back a serverless function (a Vercel API route, or a Cloudflare Worker if the app is Cloudflare-hosted) with a webhook confirming the transaction.
- The database updates. That webhook handler writes the confirmed order into Supabase Postgres, typically using a service-role key that bypasses RLS for this one trusted, server-side write.
- The email fires. The same handler (or a follow-up function) calls Resend to send a receipt or confirmation email to the customer.
- If something breaks. A client-side JavaScript error, or a server-side exception in the webhook handler, gets captured by Sentry, which alerts the team with a stack trace and enough context to reproduce it.
- Behind all of it. Every line of code in this journey reached production because it was pushed to GitHub, built and tested by GitHub Actions, and deployed automatically to Vercel or Cloudflare Pages. And before any of it was built, the checkout flow and email templates existed first as a Figma file.
Free-tier limits and the first paid upgrade
| Service | Free tier | First thing that forces an upgrade |
|---|---|---|
| Cloudflare Pages/Workers | Unlimited static requests; Workers: 100,000 requests/day, 10ms CPU/request | CPU-heavy Worker logic, or exceeding the daily request cap → Workers Paid, $5/month |
| Vercel (Hobby) | ~100k function invocations/month, 100GB bandwidth, 10s function timeout, non-commercial use only | Going commercial (Hobby's terms require it), needing longer function timeouts, or needing team seats → Pro, from $20/month/seat |
| Supabase | 500MB database, 50k monthly active users, 1GB file storage, 5GB egress, project pauses after 7 days idle, 2 active projects | Database growing past 500MB, needing the project to never pause, or needing more than 2 active projects → Pro, $25/month |
| Resend | 3,000 emails/month, capped at 100/day, 1 verified domain | Hitting the 100/day cap (common even at low volume) or needing a second domain → Pro, $20/month |
| GitHub | 2,000 Actions minutes/month on private repos (unlimited on public repos), 500MB artifact storage | CI minutes running out on an active private repo → pay-per-minute or Team plan |
| Stripe | No monthly fee; pay-per-transaction (3% + RM1 domestic) | No free-tier ceiling as such; the "upgrade" is really a rate negotiation once volume gets large |
| CHIP | No setup/monthly fee; pay-per-transaction (RM1-2 FPX flat, % on cards/e-wallets/DuitNow) | Same as Stripe: no ceiling, cost scales with transaction volume and mix |
| ToyyibPay | No setup fee; RM100/year from the second year; RM1-2 flat per FPX transaction | The yearly fee itself, and any need for features gated to a higher plan (e.g. B2B-specific tooling) |
| Figma | 3 design files, unlimited drafts, full editor | Needing a 4th real project file, or multiple paid editor seats → from ~$12-20/month/seat |
| Firebase Crashlytics | Free forever (Spark plan) | Rarely Crashlytics itself; upgrading to Blaze (pay-as-you-go) usually happens because of other Firebase products used alongside it |
| Sentry | 5,000 errors/month, 1 user, 30-day retention | Error volume climbing past 5k/month, or needing a second team member on the account → Team plan, from ~$26/month |
The pattern worth noticing: infrastructure layers (Cloudflare, Vercel, Supabase, GitHub) have hard usage ceilings that force a plan upgrade. Payment gateways have no ceiling at all, they just take a cut of every transaction from day one, so "free tier" does not really apply to them the same way.
How to choose
- If the project is a static or near-static brand/marketing site, then deploy to Cloudflare Pages; skip Vercel entirely.
- If the project is a Next.js app with real backend logic (auth, database writes, API routes), then deploy to Vercel, and reserve Cloudflare for the domain, DNS, and any edge-specific logic.
- If the client needs email login, and row-level data access control, then use Supabase Auth + RLS rather than hand-rolling access checks only in application code.
- If the app sends transactional email (receipts, password resets, welcome emails), then use Resend from day one; do not wire email through a general SMTP provider that was not built for deliverability at this scale.
- If a client's payment volume is small-ticket and mostly local (FPX, DuitNow), then default to ToyyibPay (flat, cheap per-transaction fee) or CHIP (also flat/low, plus broader method support and no yearly fee).
- If a client needs international cards, subscriptions, or a globally polished checkout experience, then use Stripe, and accept its higher local rate as the cost of that polish and reach.
- If the app needs pre-built, customizable UI fast, then pull components from shadcn/ui rather than installing a heavier component library; it stays in the codebase with no external dependency.
- If the build is a web app of any kind, then wire up Sentry before launch, not after the first production incident; never reach for Firebase Crashlytics unless it is a native mobile app.
- If the design has not been reviewed with the client yet, then build it in Figma first; do not skip straight to code for anything client-facing.
- If a credential can send money, send email, or write to the database as an admin, then it goes in
.envand the hosting platform's secrets dashboard, never in a client-side bundle or a committed file. - If a project's domain needs to serve email plus a website plus a client app on different subdomains, then keep DNS management centralized (Cloudflare), and point each subdomain's record at whichever service actually hosts that piece.
Univa playbook
Univa's default stack for a new SME client build, in order of how a request actually flows:
- Figma for the design, reviewed with the client before code starts.
- VS Code locally, GitHub for version control, GitHub Actions for CI (lint, type-check, build) on every push.
- Next.js + TypeScript strict + Tailwind + shadcn/ui for the app itself.
- Vercel for deployment (or Cloudflare Pages if the project is closer to a static brochure site, like univa.my itself).
- Supabase for auth, Postgres, RLS, and Realtime where the app needs live updates.
- Resend for every transactional email the app sends.
- ToyyibPay as the default Malaysian payment gateway for small-ticket local transactions, with CHIP as the fallback comparison when a client's transaction mix leans toward DuitNow QR or e-wallets, and Stripe only when the client explicitly needs international cards or a global-grade checkout.
- Sentry wired in before launch for every web build; Firebase Crashlytics reserved strictly for native mobile projects, which is not most of what Univa ships.
This is the same stack already running univa.my and client apps today; the change from this module is precision, not direction: knowing exactly which free-tier ceiling gets hit first on each client build, and being able to say so before it happens instead of after.
Common pitfalls to watch for on future builds:
- Quoting a client a payment gateway fee from memory instead of checking the current pricing page; all three gateways change rates without much notice.
- Leaving a Supabase service-role key inside a client-side file or a public repo instead of a server-only environment variable.
- Letting a Resend-sending app hit the 100-emails/day cap unnoticed, because the monthly cap (3,000) looked like the only limit that mattered.
- Reaching for Firebase Crashlytics on a web project out of familiarity, when Sentry is the tool actually built for that runtime.
- Deploying the same app to both Cloudflare Pages and Vercel "just in case," instead of picking one target and keeping the setup simple.
- Splitting DNS management across two providers for the same domain "temporarily," which creates confusion about where a record actually lives when something breaks.
Hands-on exercise
Pick one small idea (a waitlist page is enough) and build the full journey end to end in one evening:
- Design a one-page waitlist form in Figma (email field, submit button, confirmation state).
- Scaffold a Next.js app with shadcn/ui components matching the design, push it to a new GitHub repo, and confirm GitHub Actions runs a build on push.
- Deploy it to Vercel.
- Add Supabase: a
waitlisttable with an RLS policy that only allows inserts (no one can read anyone else's email back). - On successful sign-up, call Resend to send a confirmation email.
- Add Sentry and deliberately trigger a client-side error (throw inside a button handler) to confirm it shows up in the Sentry dashboard.
- Store every API key (Supabase, Resend, Sentry DSN) in
.envlocally and in Vercel's environment variables in production; confirm.envis gitignored before the first push. - Write down which free-tier limit, from the table above, this exact project would hit first if it suddenly went viral.
Self-check
Q1. Why does Univa use both Cloudflare Pages and Vercel instead of picking just one?
Q2. What does Row Level Security actually enforce, and why is it stronger than checking permissions only in application code?
Q3. Between Stripe, CHIP, and ToyyibPay, which is cheapest for a small Malaysian SME taking mostly FPX payments, and why?
Q4. Why is Firebase Crashlytics the wrong default choice for a Next.js web app, and what should be used instead?
Q5. In the request's journey, at what point does a service-role key (bypassing RLS) get used, and why is that acceptable there specifically?
Q6. A client's domain is registered and DNS-managed on Cloudflare, but the actual app is deployed on Vercel. What has to happen for the domain to point at the right place, and does the DNS management need to move to Vercel?
Answers:
- A1. Cloudflare Pages fits static/near-static sites (like univa.my) with a strong free tier and edge reach; Vercel is purpose-built for Next.js apps with real backend logic, offering zero-config builds and preview deployments. Each project picks one target based on what it actually needs, rather than forcing every project onto the same platform.
- A2. RLS enforces access control inside the database itself, as SQL policies tied to the authenticated user's JWT, so it applies to every query against that table regardless of which code path issued it. Application-level checks can be missed in one route and enforced in another; RLS cannot be bypassed by a forgotten
ifstatement in the frontend. - A3. ToyyibPay or CHIP, both of which charge a flat RM1 (B2C) per FPX transaction rather than a percentage; Stripe charges 3% + RM1 domestically, which costs more per transaction and scales worse with volume for small-ticket local payments.
- A4. Crashlytics is built for native mobile crash reporting (iOS/Android stack traces, ANRs) and does not naturally capture web JavaScript exceptions or Next.js server errors. Sentry is built for exactly that environment and should be the default for any web app.
- A5. The service-role key is used in the server-side webhook handler that confirms a completed payment and writes the order to the database. It is acceptable there because that code runs entirely on the server, is never exposed to the browser, and needs to write data on behalf of the system rather than on behalf of one logged-in user's own permissions.
- A6. A CNAME (or A record) for that domain or subdomain gets added in Cloudflare's DNS, pointing at Vercel's hosting servers. DNS management does not need to move; registration and DNS stay on Cloudflare, only the specific record for that hostname changes to route traffic to Vercel.
Further reading
- Cloudflare Pages documentation: https://developers.cloudflare.com/pages/
- Cloudflare DNS documentation: https://developers.cloudflare.com/dns/
- Vercel documentation: https://vercel.com/docs
- Supabase documentation: https://supabase.com/docs
- Supabase Row Level Security guide: https://supabase.com/docs/guides/database/postgres/row-level-security
- Resend documentation: https://resend.com/docs
- shadcn/ui documentation: https://ui.shadcn.com/docs
- Stripe Malaysia documentation: https://stripe.com/en-my/docs
- CHIP developer documentation: https://docs.chip-in.asia/
- ToyyibPay documentation: https://toyyibpay.com/apireference/
- Figma documentation: https://help.figma.com/
- Sentry documentation: https://docs.sentry.io/
- Firebase Crashlytics documentation: https://firebase.google.com/docs/crashlytics
- GitHub Actions documentation: https://docs.github.com/actions