Building a Modern SaaS with Next.js and Supabase

Introduction

Building a modern SaaS application requires careful consideration of technology choices and architecture decisions. In this tutorial, we'll walk through building a production-ready SaaS using Next.js 15 and Supabase, incorporating best practices for authentication, database design, and subscription management.

Tech Stack Overview

  • Frontend: Next.js 15 with App Router
  • Backend: Supabase (Database, Auth, Storage)
  • Styling: Tailwind CSS
  • Payment: Stripe Integration
  • Deployment: Vercel

Setting Up the Project

Initial Setup

# Create new Next.js project with App Router
pnpm create next-app modern-saas --typescript --tailwind --app

# Install dependencies
cd modern-saas
pnpm add @supabase/supabase-js @supabase/auth-helpers-nextjs stripe

Database Schema Design

-- Users table extension
create table public.profiles (
  id uuid references auth.users on delete cascade,
  full_name text,
  avatar_url text,
  billing_address jsonb,
  created_at timestamp with time zone default timezone('utc'::text, now()),
  primary key (id)
);

-- Teams table
create table public.teams (
  id uuid default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  logo_url text,
  created_at timestamp with time zone default timezone('utc'::text, now()),
  primary key (id)
);

-- Team members junction table
create table public.team_members (
  team_id uuid references public.teams on delete cascade,
  user_id uuid references auth.users on delete cascade,
  role text not null check (role in ('owner', 'admin', 'member')),
  created_at timestamp with time zone default timezone('utc'::text, now()),
  primary key (team_id, user_id)
);

Authentication Implementation

Setting Up Supabase Auth

// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export const createClient = () => {
  return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
}

Auth Components

// components/auth/sign-in-form.tsx
'use client'

export function SignInForm() {
  const [isLoading, setIsLoading] = useState(false)
  const supabase = createClient()

  const handleSignIn = async (data: FormData) => {
    setIsLoading(true)
    try {
      const { error } = await supabase.auth.signInWithPassword({
        email: data.get('email') as string,
        password: data.get('password') as string
      })

      if (error) throw error

      // Redirect to dashboard on success
      router.push('/dashboard')
    } catch (error) {
      toast.error('Authentication failed')
    } finally {
      setIsLoading(false)
    }
  }

  return <form action={handleSignIn}>{/* Form fields */}</form>
}

Team Management System

Creating Teams

// app/actions/teams.ts
'use server'

export async function createTeam(data: CreateTeamData) {
  const supabase = createServerClient()
  const session = await getSession()

  if (!session) {
    throw new Error('Unauthorized')
  }

  const { data: team, error } = await supabase
    .from('teams')
    .insert({
      name: data.name,
      slug: generateSlug(data.name)
    })
    .select()
    .single()

  if (error) throw error

  // Add creator as team owner
  await supabase.from('team_members').insert({
    team_id: team.id,
    user_id: session.user.id,
    role: 'owner'
  })

  return team
}

Subscription Management

Stripe Integration

// lib/stripe.ts
import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true
})

export async function createSubscription(teamId: string, priceId: string) {
  const team = await getTeam(teamId)

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: priceId,
        quantity: 1
      }
    ],
    metadata: {
      teamId
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?canceled=true`
  })

  return session
}

Webhook Handler

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'

export async function POST(req: Request) {
  const body = await req.text()
  const signature = headers().get('stripe-signature')!

  try {
    const event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)

    switch (event.type) {
      case 'customer.subscription.created':
        // Handle subscription creation
        break
      case 'customer.subscription.updated':
        // Handle subscription updates
        break
      case 'customer.subscription.deleted':
        // Handle subscription cancellation
        break
    }

    return new Response(null, { status: 200 })
  } catch (error) {
    console.error('Stripe webhook error:', error)
    return new Response('Webhook Error', { status: 400 })
  }
}

Performance Optimization

Implementing Edge Functions

// app/api/edge/usage/route.ts
export const runtime = 'edge'

export async function GET() {
  const data = await getUsageStats()
  return Response.json(data)
}

Caching Strategy

// lib/cache.ts
import { unstable_cache } from 'next/cache'

export const getTeamStats = unstable_cache(
  async (teamId: string) => {
    // Fetch team statistics
    return stats
  },
  ['team-stats'],
  {
    revalidate: 60, // Cache for 1 minute
    tags: ['team-stats']
  }
)

Best Practices and Tips

  1. Error Handling

    • Implement proper error boundaries
    • Use toast notifications for user feedback
    • Log errors to monitoring service
  2. Security

    • Implement Row Level Security (RLS) in Supabase
    • Use environment variables for sensitive data
    • Validate all user inputs
  3. Performance

    • Use Edge Functions for global deployment
    • Implement proper caching strategies
    • Optimize images and assets
  4. Development Workflow

    • Use TypeScript for type safety
    • Implement proper testing
    • Follow consistent coding standards

Conclusion

Building a modern SaaS application with Next.js and Supabase provides a solid foundation for scalability and maintainability. This stack offers:

  • Excellent developer experience
  • Built-in authentication and authorization
  • Scalable database solutions
  • Modern deployment options

Remember to always consider your specific use case and requirements when implementing these patterns.

Next Steps

  1. Implement more advanced features
  2. Add comprehensive testing
  3. Set up monitoring and analytics
  4. Plan for scaling

Resources