7 Next.js + Supabase Architecture Decisions I'd Make Differently
After shipping multiple production apps with Next.js and Supabase, here are the decisions that cost the most time to undo — and what I'd do instead from day one.
7 Next.js + Supabase Architecture Decisions I'd Make Differently#
The best time to make good architecture decisions is before you have 50,000 users and a codebase that's grown past the point where refactoring is easy.
These are the decisions that cost me the most time to undo. Not theoretical mistakes — actual things I shipped, regretted, and had to fix under pressure.
1. Using the Supabase Client Directly in Components#
Early on, I called supabase.from('posts').select() directly inside React components. It worked. Then I needed to add caching. Then I needed to add error handling. Then I needed to change the query shape. I was doing it in 15 different places.
What I'd do instead: create a data access layer from day one.
// src/lib/data/posts.ts
export async function getPostsByUser(userId: string) {
const supabase = await createClient()
const { data, error } = await supabase
.from('posts')
.select('id, title, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
if (error) throw error
return data
}
One function, one place to change, one place to add caching. Components just call getPostsByUser(userId).
2. Storing User Roles in JWT Metadata#
I stored roles in user_metadata because it was easy. Then I needed to change a user's role. The change took effect... when their token expired. An hour later. In production.
JWT claims are set at login time and don't update until the token refreshes. For anything that needs to change in real time — roles, permissions, subscription status — store it in a database table and look it up at query time.
-- Do this instead
CREATE TABLE user_roles (
user_id UUID PRIMARY KEY REFERENCES auth.users(id),
role TEXT NOT NULL DEFAULT 'member'
);
Your RLS policies query this table. Role changes take effect immediately.
3. Not Setting Up the Pooler from Day One#
I used the direct Postgres connection string during development. It worked fine. I deployed to Vercel. It worked fine. Traffic grew. too many connections errors started appearing at peak hours.
Switching to the connection pooler (port 6543) mid-production required updating environment variables, testing connection behavior, and dealing with the prepared statements incompatibility in Drizzle.
The fix takes 5 minutes on a new project. It takes 2 hours on a live one.
Use DATABASE_URL pointing to the pooler (port 6543) from the start. Use DIRECT_URL pointing to port 5432 only for migrations.
4. Skipping TypeScript for Supabase Queries#
"I'll add types later." I didn't add types later. Six months in, I had no idea what shape data was in half my queries. Refactoring the database schema meant grep-searching for column names and hoping I found them all.
Supabase generates TypeScript types from your schema:
supabase gen types typescript --project-id your-project-id > src/types/database.ts
Then use them:
import type { Database } from '@/types/database'
type Post = Database['public']['Tables']['posts']['Row']
Your editor catches schema mismatches before runtime. This alone saves hours per week on a growing codebase.
[INTERNAL LINK: nextjs-supabase-type-safety-guide]
5. Putting Business Logic in RLS Policies#
RLS is for access control, not business logic. I started adding conditions like "users can only create posts if their subscription is active" directly in policies. It worked, but:
- Debugging was painful (silent failures, remember?)
- The logic was split between the database and the application
- Testing required simulating database state, not just function inputs
Business logic belongs in Server Actions or API routes where it's testable, readable, and can return meaningful errors. RLS should answer one question: "does this user have permission to see/modify this row?"
// Business logic in Server Action — testable, readable
export async function createPost(data: PostInput) {
const user = await getAuthenticatedUser()
const subscription = await getUserSubscription(user.id)
if (!subscription.isActive) {
return { error: 'Active subscription required to create posts' }
}
// Now insert — RLS just checks user_id = auth.uid()
const { error } = await supabase.from('posts').insert({
...data,
user_id: user.id,
})
}
6. Not Planning for Multi-Tenancy Early Enough#
I built a single-user app. Then a customer asked for team accounts. Adding org_id to every table, updating every query, rewriting every RLS policy — it took two weeks.
If there's any chance your app will have teams, organizations, or shared resources, add an org_id column to your core tables from the start. Even if you don't use it immediately, the migration cost is near zero early on and enormous later.
-- Add this even if you're not using it yet
ALTER TABLE projects ADD COLUMN org_id UUID REFERENCES organizations(id);
[INTERNAL LINK: nextjs-supabase-multi-tenant-saas-architecture]
7. Using getSession() Instead of getUser() for Auth Checks#
This one is a security issue, not just a maintenance issue.
getSession() reads the session from the cookie without validating it with the Supabase Auth server. A tampered cookie returns a session object. getUser() makes a network call to validate the JWT — it's the only safe option for server-side auth checks.
I used getSession() everywhere because it was faster (no network call). Then I read the Supabase security docs more carefully.
// Wrong — don't use for auth checks
const { data: { session } } = await supabase.auth.getSession()
// Right — validates with Auth server
const { data: { user } } = await supabase.auth.getUser()
The performance difference is negligible. The security difference is not.
None of these are exotic mistakes. They're the obvious shortcuts that feel fine until they don't. The common thread: decisions that are cheap to make correctly at the start become expensive to fix under load.
The good news: if you're reading this before you've shipped, you can avoid all of them. If you've already shipped some of them — so did I, and they're all fixable.
What architecture decisions have cost you the most time to undo? Drop them in the comments.
Continue Reading
7 Things I Wish I Knew Before Scaling Next.js + Supabase to 100K Users
Hard lessons from taking a Next.js and Supabase app from MVP to production scale. The mistakes that cost us hours, the patterns that saved us, and what I would do differently.
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.
I Cut My Next.js + Supabase App Load Time by 73% - Here Are the 5 Techniques That Actually Worked
Real performance optimization results from a production SaaS app. These battle-tested techniques reduced load times from 4.2s to 1.1s and improved Core Web Vitals scores across the board.
Browse by Topic
Find stories that matter to you.