Skip to content

TECHNOLOGY

Building multi-tenant SaaS with Next.js and Supabase

From PTLog, ClinicPulse, HuntLine IQ and PeakUp: tenant isolation in RLS, authorisation tests in SQL, hosting on Vercel or Cloudflare.

SHORT ANSWER

AMG Software Studio builds SaaS products with a Next.js front end and Supabase for the database, auth and Edge Functions, leaving tenant isolation to Postgres RLS rather than application filters. ClinicPulse’s ten tables and 34 RLS policies, PTLog’s tenant claims written into the session JWT and HuntLine IQ’s 28 migrations and 25 Edge Functions are examples of the approach.

Which projects use Next.js and Supabase together?

Projects that use both need to be kept apart from projects that use only one, because the stack is not the same everywhere:

  • Next.js + Supabase: PTLog, ClinicPulse, PsiLog, Kutylawsuit, EntMoment, Photonza, Sorudaş, ADMERD, HuntLine IQ’s web app (Next.js 14), PeakUp’s admin panel (Next.js 16), and Karaman’s restaurant and admin panels.
  • Supabase without Next.js: ozelokul.com’s front end is Vite + TanStack Start; RichlyMe, GlowTira and Macsug are mobile apps written in Expo.
  • Next.js without Supabase: FitZen’s website and Garaj’s admin panel use Firestore; Sora Okulları and Prime Tobacco are static Next.js sites; our own site, amgsoftwarestudio.com, is Next.js 16 running on Cloudflare Workers.

The distinction matters because some of the RLS and testing practice below comes from ozelokul.com and RichlyMe. Those are evidence for the Supabase side, not the Next.js side. For tenant resolution, cron routes and hosting on the Next.js side, the examples are PTLog, ClinicPulse, HuntLine IQ and our own site.

Multi-tenancy: where is the tenant resolved?

The first question in a multi-tenant SaaS is where a request gets assigned to a tenant. In our projects the answer is: the tenant goes into the session, and the database enforces isolation.

In PTLog, every gym is a tenant on one codebase. Next.js middleware resolves gym.ptlog.dev or a custom domain to the tenant, and Supabase’s custom_access_token_hook writes tenant, role and permission claims into the session JWT. A branding engine serves a separate PWA manifest and sharp-generated icons per hostname, so members add the gym’s own app to their home screen.

In ClinicPulse, tenant isolation lives in ten tables and 34 RLS policies; nothing relies on application code remembering where clinic_id = …. Adding a clinic is one row, with no separate deployment. Sensitive fields are encrypted with AES-256-GCM, and the app refuses to start without the key.

In ozelokul.com, the tenant is the brand and the campus is a foreign-keyed scope inside it. Roles are 56 permission keys rather than an enum; every check goes through private.has_perm(), and membership and scope are written in a single transaction.

How do we test Supabase RLS policies?

Reading a policy and deciding it looks right is not a test. Our tests switch to the client’s role and actually attempt the forbidden operation:

  • RichlyMe: 401 tests across 38 files run on a throwaway Postgres cluster without Docker, with all 49 migrations applied. Every test runs inside BEGIN … ROLLBACK; test.as_user() sets the JWT claims and test.throws() checks the expected SQLSTATE, usually 42501. Structural tests assert "no table without RLS" and "anon can reach nothing", which also covers tables added later.
  • ozelokul.com: 560 assertions in 33 pgTAP files. The first two assertions of the privileged-column suite check that the test runs as authenticated, because a superuser bypasses RLS and everything passes for nothing. In a mutation test, removing campus scope from has_perm turned the suite red on 5 assertions.
  • PTLog: schema, RLS and security tests run on local Postgres 17, and 45 end-to-end tests run against the production build in about six minutes.
  • HuntLine IQ: pgTAP tests through supabase test db, with CI on every push.
  • PeakUp: 36 database and RLS tests behind the pnpm dogrula gate.

Where is RLS alone not enough?

RLS protects rows, not columns. During the ozelokul.com authorisation audit we showed with a live attempt that a "users may update their own profile" policy opens the whole row: a parent account could set its own account_type to platform with a single PATCH request. Five places of the same kind were closed with column grants and a trigger. Parent contact details are masked in a view with case when contact_revealed.

The second trap is the policies themselves. Once EXECUTE was revoked on private-schema functions, every policy failed with permission denied; the fix was granting EXECUTE only on the 9 functions policies reference. A policy that read another RLS table failed with 42P17, infinite recursion, and that access moved into a SECURITY DEFINER function.

The third is the gap between screen and API. In RichlyMe, a blurred proof’s feed row still carried the owner’s name and a publication time to the second: the screen hid it, the API handed it out. Plan limits follow the same principle; in ozelokul.com a locked section never comes back from the query at all, because of private.section_visible().

Database tests do not cover server functions, so at ozelokul.com 80 mutation endpoints were also mapped to their gates and 30 went through live exploit attempts with real JWTs.

How do we organise Edge Functions, cron and webhooks?

In RichlyMe, business rules live in SQL while auth and input validation sit in thin Edge Function shells: authenticate, validate, call one RPC. Nine of the 17 functions can only be called with the service role. Our Next.js projects follow a similar layout:

  • HuntLine IQ: 25 Edge Functions. Because Stripe and RevenueCat send their own authentication, those two webhooks deploy with --no-verify-jwt; the other 23 verify the member’s token. Daily cron runs through /api/cron/* routes on the Next.js side.
  • ClinicPulse: 24-hour WhatsApp reminders go out from the api/cron/reminders route, and the send endpoint is capped at ten requests a minute.
  • PeakUp: pg_cron and triggers authenticate to Edge Functions with a shared secret in the x-peakup-cron header, and edge_cagri_sagligi(24) feeds the last 24 hours of 401s and 5xx errors to the panel’s system status card. A missing vault secret makes the cron job throw; nothing dies silently.
  • Photonza: photographer uploads enter a durable Postgres queue, and a Cloudflare container waking every two minutes claims jobs with FOR UPDATE SKIP LOCKED. Event, invite and credit are created in one database transaction, safe to retry with the same request id.

Should Next.js run on Vercel or on Cloudflare?

We run products on both. HuntLine IQ’s Next.js 14 web app is live on Vercel, and the native mobile apps read the same Supabase data. In Viralance, media is served from Cloudflare R2 with Vercel as the fallback.

Our own site, amgsoftwarestudio.com, compiles its Next.js 16 App Router code with vinext and runs it as a single Cloudflare Worker. The Worker keeps public HTML from anonymous GET requests in the Cache API for 300 seconds and adds the deployment version id to the cache key, so each release starts with fresh keys and there is no purge step. In a small sample from one location on 31 October 2026, cached pages returned their first byte in 0.15–0.25 seconds. Instead of relying on Next.js image optimisation on Workers, images are generated at seven widths during the build and mapped with a custom loader.

On our own site, publishing is manual on purpose: GitHub Actions only validates types, lint, tests and the production build, and CI holds no Cloudflare deployment secret. For client projects, hosting is chosen during discovery, and the domain, servers, database and service accounts are opened in the client’s name.

Frequently asked questions

With Supabase RLS in place, do you still need authorisation checks in the app?

Yes, as the second line. In ozelokul.com every table is born with RLS, and application checks are the second defence. Database tests do not cover server functions, so 80 mutation endpoints were mapped to their gates and 30 were tried live with real JWTs. Two of the four exploited findings had no RLS counterpart either, so a second line of defence was added for them.

Can you build a Supabase backend under an existing front end?

Yes. At ozelokul.com the client had a finished design running entirely on mock data, about 53 routes and 50,000 lines. The brief was to build a production-grade backend underneath without touching a pixel. Every field of the mock data was mapped to the schema first, then loaded as a seed migration; there are 139 migrations today. Note that this project’s front end is Vite + TanStack Start, not Next.js.

Does each of our customers need a separate deployment?

Not when the product is built multi-tenant. In ClinicPulse a new clinic is one row, with no separate deployment. In PTLog every gym opens on the same codebase under its own subdomain or custom domain and gets its own branded PWA. Plan differences can live in data too, as plan_features rows in ozelokul.com, rather than in code.

What do you use for payments?

It depends on the product. In HuntLine IQ, Stripe and RevenueCat webhooks run as Edge Functions; Kutylawsuit uses Stripe Checkout with webhook verification; EntMoment has Stripe and a weekly payout cron. At ozelokul.com there is no payment provider, by client decision: the flow runs on bank transfer, and admin approval marks the invoice paid, activates the subscription and writes the audit record in one transaction.

Can a Next.js app run on Cloudflare Workers?

Yes, our own site runs that way. The Next.js 16 code behind amgsoftwarestudio.com is compiled with vinext through Vite into a single Worker bundle, with React Server Components and server-rendered HTML in the same bundle. Redirects and small API endpoints are resolved in the Worker entry file before the app sees the request. There is no dependency on paid Cloudflare Images; images are prepared at build time.

Can the same Supabase project serve a mobile app too?

Yes, several of our projects work that way. HuntLine IQ’s Expo app uses the same data model and Supabase project as the web platform, with authorisation enforced by RLS in the database. PeakUp’s Next.js 16 panel shares the schema and the @peakup/shared business rules with the mobile app. In Karaman, the customer mobile app, the restaurant panel and the central admin panel share one Supabase project.

Who owns the source code and accounts?

You do. The domain, servers, database and service accounts are opened in your name. At delivery we hand over the full source code, the design files and every credential.

Do you use templates or off-the-shelf admin panels?

No. We design the user screens and the admin panel screens for your project and build them from scratch, so the panel follows your workflow.

RELATED STUDIO NOTES

If you have an idea in mind,
let’s talk.

It starts with an intro call. We define the scope and timeline together, then send you a written proposal.

hello@amgsoftwarestudio.com