Building with Next.js + Supabase | EliteSaas

Complete guide to building SaaS applications with Next.js + Supabase. Next.js with Supabase backend.

Why Next.js + Supabase is a high-velocity stack for SaaS

The nextjs-supabase combo gives you a fast path from idea to production. Next.js brings file-based routing, server components, and built-in API routes. Supabase delivers Postgres with row level security, auth, storage, and real-time subscriptions. Together they handle the most common SaaS needs without heavy orchestration.

If you want a practical stack guide, think in terms of outcomes. You need to ship a secure product with user accounts, a polished dashboard, usage tracking, role-based access, and payments. With Next.js + Supabase, you get strong primitives that map cleanly to those outcomes. If you prefer a prebuilt scaffold, EliteSaas packages opinionated patterns for the same stack so teams can move faster.

Architecture overview of a typical nextjs-supabase app

A production SaaS built with Next.js + Supabase usually follows this architecture:

  • Next.js App Router for UI, server components, and route handlers under app/.
  • Supabase Postgres as the system of record with strict row level security policies.
  • Supabase Auth for email, OAuth, and magic links with JWTs passed to PostgREST under the hood.
  • Server-side data fetching in server components or route handlers to keep secrets off the client.
  • Edge or Node runtimes per route, chosen for latency and supported libraries.
  • Background jobs using Supabase functions, cron, or external queues for long-running tasks.
  • Type-safe queries by generating TypeScript types from your database schema.

At a high level, the browser talks to Next.js pages and API routes. Your server code creates a Supabase client with the user's session so RLS enforces scope automatically. When you need to bypass RLS for administrative tasks, you call a server-only client initialized with a service role key.

Setup and configuration

Install dependencies

# with pnpm
pnpm add next react react-dom @supabase/supabase-js @supabase/ssr zod

# with npm
npm install next react react-dom @supabase/supabase-js @supabase/ssr zod

Environment variables

Create .env.local and add your project credentials:

NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>

# Server-only
SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>

Never expose the service role key to the browser. It should only be referenced in server code.

Supabase client helpers for Next.js

Create small helpers to initialize the client in different contexts. Using @supabase/ssr keeps cookies in sync with Next.js middleware and server components.

lib/supabase/server.ts - server components and route handlers:

import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'

export function createServerSupabase() {
  const cookieStore = cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name) {
          return cookieStore.get(name)?.value
        },
        set(name, value, options) {
          cookieStore.set({ name, value, ...options })
        },
        remove(name, options) {
          cookieStore.set({ name, value: '', ...options })
        },
      },
    }
  )
}

lib/supabase/client.ts - browser usage in client components:

import { createBrowserClient } from '@supabase/ssr'

export const supabaseBrowser = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

Authentication flows

Use Supabase Auth for email + password, magic links, or OAuth. In a route handler:

// app/api/auth/sign-in/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { createServerSupabase } from '@/lib/supabase/server'

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
})

export async function POST(req: Request) {
  const supabase = createServerSupabase()
  const body = await req.json()
  const { email, password } = schema.parse(body)

  const { error } = await supabase.auth.signInWithPassword({ email, password })
  if (error) {
    return NextResponse.json({ error: error.message }, { status: 401 })
  }

  return NextResponse.json({ ok: true })
}

Protect routes via middleware that checks session state and redirects unauthenticated users.

// middleware.ts
import { NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'

export async function middleware(req: Request) {
  const url = new URL(req.url)
  const res = NextResponse.next()

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { get: (n) => res.cookies.get(n)?.value, set: () => {}, remove: () => {} } }
  )

  const { data: { session } } = await supabase.auth.getSession()

  if (!session && url.pathname.startsWith('/app')) {
    return NextResponse.redirect(new URL('/sign-in', url))
  }

  return res
}

export const config = { matcher: ['/app/:path*'] }

Database schema and RLS

Define a minimal schema for user profiles and organizations. Then enable RLS and write policies that scope reads and writes to the current user or their organization.

-- migrations/001_init.sql
create table profiles (
  id uuid primary key references auth.users not null,
  full_name text,
  created_at timestamptz not null default now()
);

create table organizations (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  owner_id uuid not null references auth.users,
  created_at timestamptz not null default now()
);

create table memberships (
  org_id uuid not null references organizations,
  user_id uuid not null references auth.users,
  role text not null check (role in ('owner','admin','member')),
  primary key (org_id, user_id)
);

alter table profiles enable row level security;
alter table organizations enable row level security;
alter table memberships enable row level security;

-- Only the user can see or edit their profile
create policy "read own profile" on profiles
for select using (auth.uid() = id);

create policy "update own profile" on profiles
for update using (auth.uid() = id) with check (auth.uid() = id);

-- Users can see their orgs via membership
create policy "read orgs by membership" on organizations
for select using (
  exists (
    select 1 from memberships m
    where m.org_id = organizations.id
      and m.user_id = auth.uid()
  )
);

Type-safe queries

Generate TypeScript types from your database to keep queries type-safe:

# Install CLI
npm i -g supabase

# Sign in and link project
supabase login
supabase link --project-ref <project-id>

# Generate types
supabase gen types typescript --linked > src/types/database.ts

Use the generated types with your Supabase client:

import type { Database } from '@/types/database'
import { createServerSupabase } from '@/lib/supabase/server'

export async function getOrganizations() {
  const supabase = createServerSupabase()
  const { data, error } = await supabase
    .from<Database['public']['Tables']['organizations']['Row']>('organizations')
    .select('*')
  if (error) throw error
  return data
}

Development best practices for production SaaS

Prefer server components for data access

Read data in server components or route handlers whenever possible. This keeps secrets server-side, reduces bundle size, and leverages RLS effectively. Use client components only for interactive pieces that need browser APIs.

Encapsulate access patterns

Create a thin data access layer with small functions that map to business operations. It simplifies refactors and security audits. Example:

// src/data/organizations.ts
import { createServerSupabase } from '@/lib/supabase/server'
import { z } from 'zod'

const createOrgSchema = z.object({ name: z.string().min(3) })

export async function listUserOrgs() {
  const supabase = createServerSupabase()
  const { data, error } = await supabase.from('organizations').select('*').order('created_at', { ascending: false })
  if (error) throw error
  return data
}

export async function createOrg(payload: unknown) {
  const { name } = createOrgSchema.parse(payload)
  const supabase = createServerSupabase()
  const { data, error } = await supabase.from('organizations').insert({ name }).select().single()
  if (error) throw error
  return data
}

Validate at boundaries

  • Use zod or valibot to validate API payloads and forms.
  • Apply database constraints like not null, check, and unique to enforce invariants.
  • Write RLS policies that align with your roles and permissions model.

Secure by default

  • Enable RLS on every table and write least-privilege policies.
  • Keep the service role key only in server-side code and secrets managers.
  • Use short-lived sessions and refresh tokens via the built-in Supabase Auth flow.
  • Strip PII from logs and error reports.

Local development with containers

The Supabase CLI can run Postgres and services locally so you can iterate without touching production:

supabase start      # boot local stack
supabase status     # see services
supabase stop       # shut down

Commit SQL migrations to version control. Never rely on ad hoc manual changes in production. Use supabase db diff to generate migration files from schema changes.

Observability

  • Add application logging with structured metadata, including user IDs and org IDs.
  • Set up error tracking for server components and API routes.
  • Create database dashboards for slow queries and index health.

Product iteration loop

Build small, vertical slices that start at the UI and end at the schema. Ship behind feature flags, seed with realistic dev data, and capture usage events. For go-to-market checklists and fundamentals, see SaaS Fundamentals for Startup Founders | EliteSaas.

Deployment and scaling on Vercel + Supabase

Deploying Next.js

  • Push your repo to GitHub, GitLab, or Bitbucket and import it into Vercel.
  • Set environment variables in Vercel for NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.
  • Use the Node runtime for routes that need native modules. Use the Edge runtime for latency sensitive APIs that only use web-standard APIs.

Deploying Supabase

  • Create your project in the Supabase dashboard and choose a region close to your users.
  • Apply SQL migrations automatically via CI using supabase db push or psql.
  • Set up nightly backups and retention policies for compliance.

Performance strategies

  • Caching: Use Next.js route handlers to wrap Supabase reads with revalidate controls. For example, cache list endpoints for 30 seconds when staleness is acceptable.
  • Indexes: Add B-tree indexes for columns used in filters and sorting. The query planner rewards clear, selective indexes.
  • Connection pooling: Supabase manages pooling for you. Keep queries short and avoid chatty patterns from the client.
  • Parallelism: Use Promise.all in server code for independent queries to reduce TTFB.

Background jobs and webhooks

For long-running tasks such as PDF generation or third party syncs:

  • Use database triggers or pg_cron for simple scheduled jobs.
  • Use Supabase Functions for stateless webhooks or lightweight task runners.
  • For heavy workloads, push tasks to a queue and process with a worker on a dedicated runtime.

Security and compliance in production

  • Rotate keys regularly and store them in a secrets manager.
  • Restrict network ingress by IP when appropriate for admin interfaces.
  • Enable row level security by default and use the service role only for controlled admin endpoints.
  • Implement data retention and delete workflows for user requests.

Cost control

  • Measure query frequency and payload sizes. Avoid N+1 fetches with joined selects or denormalized materialized views if reads dominate.
  • Prefetch data for routes with predictable access patterns. Use partial hydration and suspense boundaries to keep interactions snappy.
  • Scale storage and bandwidth via object storage policies and lifecycle rules.

Conclusion

The next.js + supabase stack hits a sweet spot for startup speed and operational confidence. Next.js gives you a modern React runtime with server-first ergonomics. Supabase gives you a production-grade Postgres with batteries included. If you want to skip boilerplate and focus on product, EliteSaas provides a refined project structure, secure defaults, and implementation patterns that blend cleanly with this stack.

After deployment, iterate quickly, reinforce your schema and policies, and capture feedback loops. When you are ready to tune pricing and packaging, this stack keeps your paths to experiment short. For hands-on guidance on pricing, see Pricing Strategies for Indie Hackers | EliteSaas.

FAQ

How do I choose between server components and client components with Supabase?

Default to server components for reads and writes to keep secrets off the client and let RLS do the heavy lifting. Use client components when you need instant interactivity, optimistic UI, or browser-only APIs. You can still call Supabase from the client for simple reads that do not require sensitive filters, but keep mutations in server actions or route handlers.

Is Next.js Edge runtime compatible with the Supabase JavaScript client?

Yes, the Supabase client uses fetch and works in the Edge runtime. Keep in mind that some Node-only libraries are not available at the edge. If a route depends on such libraries or needs large compute time, use the Node runtime for that handler.

How do I handle multi-tenancy with RLS?

Create an organizations table and a memberships join table. Store the current user's organization context in the UI and pass it in queries when required. Write RLS policies that scope reads and writes to rows where the user is a member. Avoid dynamic policy logic that depends on client-provided org IDs without verifying membership in the database.

What is the best way to seed local data?

Use SQL seed files or scripts that insert realistic sample users, organizations, and records. Keep seeds deterministic so you can reproduce issues. Run seeds as part of local supabase start or a package script. Avoid shipping seed code in production builds.

Can I mix Supabase Auth with an external identity provider?

Yes. Supabase supports OAuth out of the box and can be configured with providers like GitHub and Google. If you use an external IdP, you can still map identities to the same auth.users table and rely on RLS for authorization.

Ready to get started?

Start building your SaaS with EliteSaas today.

Get Started Free