How to Build a Custom Web App with Next.js and Supabase in 2026
Three years ago, Firebase was the default backend for solo developers and small teams shipping web apps. It was fast to set up, well-documented, and integrated tightly with Google’s ecosystem. But the trade-offs became clear as projects matured: a proprietary NoSQL data model that fought against relational queries, vendor lock-in that made migration painful, and pricing that scaled unpredictably once you passed the free tier. Something better was needed.
That something turned out to be Supabase — an open-source Firebase alternative built on top of PostgreSQL. Paired with Next.js and deployed on Vercel, it gives you a full production stack with zero DevOps overhead. Auth, database, file storage, real-time subscriptions, edge functions — all backed by a real Postgres database you actually own.
I’ve shipped multiple production applications with this stack, including MurmReps, a product discovery platform with 15,000+ items. This guide covers everything I’ve learned — the architecture decisions, the real-world pitfalls, and the patterns that actually work in production.
What you get out of the box
Supabase is not just a database. When you create a project, you get an entire backend platform ready to use:
- Authentication: Email/password, OAuth providers (Google, GitHub, Discord, and more), magic links, and phone auth. User management, session handling, and JWT tokens are all handled for you.
- PostgreSQL database: A full Postgres instance with a visual table editor, SQL editor, and database management tools built into the dashboard. You write real SQL, not proprietary query syntax.
- Row Level Security (RLS): Postgres-native security policies that control which rows each user can read, insert, update, or delete. Your security logic lives in the database itself, not scattered across API endpoints.
- Real-time subscriptions: Listen for database changes over WebSockets. When a row is inserted, updated, or deleted, connected clients receive the change instantly.
- File storage: S3-compatible object storage with access policies tied to your auth system. Upload images, documents, or any file type with built-in CDN delivery.
- Edge Functions: Deno-based serverless functions that run close to your users. Useful for webhooks, scheduled tasks, and any server-side logic that doesn’t fit in a Next.js API route.
The key insight here is portability. Your data lives in a standard Postgres database. If you ever outgrow Supabase or want to self-host, you can export your entire database and run it on any Postgres-compatible service. Try doing that with Firestore.
The architecture
Next.js App Router and Supabase work together in a clean, predictable pattern. The division of responsibility is straightforward once you understand where each piece fits.
Server Components handle reads. When a page loads, Server Components run on the server and can query Supabase directly using the server-side client. No API layer needed, no loading spinners, no client-side fetch waterfalls. The data is already in the HTML when it reaches the browser. You create a server client using createServerClient from the @supabase/ssr package, passing it the cookies from the request.
Client Components handle interactivity. For real-time features, auth state changes, and user interactions, you use the browser-side Supabase client created with createBrowserClient. This client uses the anon key — a public key that respects your RLS policies. It’s safe to expose in frontend code because RLS ensures users can only access data they’re authorized to see.
API routes handle writes with business logic. When you need server-side validation, computed fields, or operations that require elevated permissions, you create Next.js API routes. These use the service_role key, which bypasses RLS entirely. This key must never be exposed to the client — it goes in server-side environment variables only.
The pattern looks like this in practice: a product page is a Server Component that fetches product data from Supabase and renders it as static HTML with ISR. The “like” button on that page is a Client Component that uses the browser client to insert a row into the likes table. And the admin endpoint that recalculates product scores is an API route using the service_role key to update all products at once.
What I learned building MurmReps
MurmReps is a product discovery platform I built for the replica fashion community. It indexes 15,000+ products across dozens of sellers, with filtering, search, quality ratings, and user engagement features. Every lesson below comes from shipping and running this application in production.
RLS must explicitly grant permissions to the anon role. This tripped me up early. When you enable Row Level Security on a table, the default behavior is deny-all. No policy means no access — not even for reads. The confusing part is that Supabase doesn’t return an error when a query hits a table with no matching policy. It returns an empty array. You’ll stare at your code wondering why your query returns nothing, check the SQL in the dashboard (where it works because the dashboard uses the service_role), and lose hours before realizing the anon role simply has no SELECT policy.
Server-side scoring prevents gaming. MurmReps ranks products using a weighted algorithm that combines view count, like count, QC rating, recency, and variant count. This scoring runs entirely server-side through an API route. If you computed scores client-side or stored the weights in the frontend, someone could reverse-engineer the formula and game it. By keeping the computation on the server with the service_role key, the ranking stays honest.
ISR gives you the best of both worlds. Product pages use Incremental Static Regeneration with a five-minute revalidation window. The first visitor triggers a static page build. For the next five minutes, every visitor gets that cached static page — instant load times, no database queries. After five minutes, the next request triggers a background rebuild with fresh data. You get static-site speed with near-real-time data. For a catalog of 15,000+ products, this means the vast majority of page views never touch the database at all.
Search performance is better than you’d expect. Supabase’s .ilike() method with proper column indexes delivers sub-80ms search results across the entire product catalog. We support 45 different filters (category, seller, price range, quality rating, and more) and the queries remain fast because PostgreSQL’s query planner is remarkably good at combining indexes. You don’t need Elasticsearch or Algolia for a dataset this size — Postgres handles it natively.
Internal tools don’t need a full auth system. The MurmReps admin panel is a Next.js route group (/admin) protected by a simple password stored in an environment variable. The password is checked against a value in sessionStorage on the client and verified via an API route header on the server. There’s no user registration, no OAuth flow, no session database. For a single-user admin panel, this is the right level of complexity. Don’t over-engineer your internal tools.
Common mistakes
After building with this stack for over a year, these are the mistakes I see most often — including ones I made myself.
Not enabling RLS from day one. Every table you create in Supabase starts with RLS disabled. This means any user with your anon key (which is public) can read, write, and delete any row in that table. The dashboard shows a warning, but it’s easy to ignore during development. Enable RLS on every table immediately after creating it, then add policies as needed. The deny-all default is a feature, not a bug — it forces you to think about access control explicitly.
Exposing the service_role key in frontend code. The service_role key bypasses all Row Level Security policies. It’s the master key to your database. If this key ends up in your client-side JavaScript — through a .env file prefixed with NEXT_PUBLIC_, or hardcoded in a component — anyone can inspect your page source and gain full access to your database. The service_role key belongs in server-side environment variables (SUPABASE_SERVICE_ROLE_KEY, without the NEXT_PUBLIC_ prefix) and should only be used in API routes and Server Components.
No rate limiting on public API routes. If your Next.js app has API routes that write to the database, someone will find them. Bots, scrapers, and curious developers will send thousands of requests. Without rate limiting, they can fill your database with junk data, exhaust your Supabase quota, or worse. Use middleware-level rate limiting with something like Upstash Redis or Vercel’s built-in rate limiting. At minimum, add basic IP-based throttling to any public-facing endpoint.
Skipping auth checks on API routes. Every admin or protected API route must verify the caller’s identity before doing anything. Whether you’re checking a session token, verifying a password header, or validating a JWT, the check must happen on every single request. Don’t assume that because an endpoint isn’t linked in your UI, nobody will find it. API routes are public URLs — treat them that way.
What it costs to run
One of the biggest advantages of this stack is cost predictability. Here’s what a production application actually costs per month:
- Supabase: The free tier includes 500MB of database storage, 1GB of file storage, 50,000 monthly active users for auth, and 500MB of bandwidth. That’s enough for most MVPs and early-stage products. When you need daily backups, more storage, or higher limits, the Pro plan is $25/month.
- Vercel: The Hobby tier is free and handles personal projects with reasonable traffic. The Pro plan at $20/month unlocks team features, more bandwidth, longer build times, and analytics. For most solo-developer projects, the free tier is sufficient.
- Cloudflare: Free for DNS management and CDN caching. The free plan includes DDoS protection, SSL certificates, and basic analytics. You don’t need to pay for Cloudflare unless you’re doing something unusual.
- Domain: Roughly $10–$15 per year depending on the TLD. A
.comor.devdomain costs about a euro per month.
Total: under $50/month for a production application serving thousands of users with authentication, a full relational database, file storage, and global CDN delivery. Many projects run entirely on free tiers during development and early traction.
Compare that to a traditional setup. A Rails application on Heroku starts at $7/month for a basic dyno, but you need a database add-on ($9/month), a Redis instance for background jobs ($15/month), and separate services for auth, file storage, and email. You’re looking at $50–$100/month before your first user signs up, plus the DevOps overhead of managing it all.
An AWS setup is even more involved. EC2 instances, RDS databases, S3 buckets, CloudFront distributions, ALB load balancers, ACM certificates, IAM roles — each with its own pricing model and configuration surface. You can absolutely build a cheaper, more scalable infrastructure on AWS, but the operational complexity is orders of magnitude higher. For solo builders and small teams, that complexity is the actual cost.
The Next.js + Supabase + Vercel stack eliminates operational overhead entirely. You push to Git, Vercel builds and deploys. You create a table in the Supabase dashboard, write an RLS policy, and your API is ready. There’s no server to SSH into, no Docker containers to manage, no CI/CD pipeline to maintain. You spend your time building features, not fighting infrastructure.
Need help building your app?
I’ve shipped production Next.js + Supabase platforms. Tell me what you’re building.
Get in touch →