Why Next.js 15, Supabase, and Groq Are the Production SaaS Stack

Building a SaaS product in 2026 demands a stack that gives you speed to ship, genuine security by default, and AI capabilities without a PhD in infrastructure. The combination of Next.js 15 App Router, Supabase for auth and database, and Groq for inference hits all three marks. Next.js gives you React Server Components so your data fetching is tight and lean. Supabase gives you Postgres with Row Level Security baked in so your multi-tenant isolation is enforced at the database layer — not just in your application code. Groq gives you sub-second LLM inference via API, meaning your AI features respond fast enough that users stay in flow.

This guide walks through every layer of this stack in production detail: how to structure your app with route groups, how to configure Supabase's two different clients correctly, how to write RLS policies that actually work, how to call Groq safely on the server, and how to ship the whole thing to Vercel with correct environment variable hygiene.

App Router Architecture: Server vs Client Components

In Next.js 15, every component inside the app/ directory is a React Server Component by default. Server Components run exclusively on the server — they can read environment variables, query databases directly, and never ship their source code to the browser. This is a fundamental security and performance gain compared to the old Pages Router where everything eventually ran client-side.

The rule of thumb: if a component does not need useState, useEffect, event handlers, or browser-specific APIs, keep it as a Server Component. Add the "use client" directive only at the outermost component that actually needs interactivity — this keeps your JavaScript bundle small and keeps sensitive logic off the client.

Route Groups for Auth Layout Separation

Route groups — folders wrapped in parentheses like (auth) and (app) — let you apply different layouts to different parts of your application without affecting the URL structure. Your SaaS will typically need two distinct layouts: a minimal centered layout for login/signup pages, and a full sidebar layout for the authenticated dashboard. Without route groups, you end up with messy conditional logic in a single layout.tsx.

A clean App Router structure looks like this:

app/
├── (auth)/
│   ├── layout.tsx          # Minimal layout: centered card, no sidebar
│   ├── login/page.tsx
│   └── signup/page.tsx
├── (app)/
│   ├── layout.tsx          # Full layout: sidebar, topbar, user menu
│   ├── dashboard/page.tsx
│   └── settings/page.tsx
├── layout.tsx              # Root layout: html/body, global CSS, providers
└── middleware.ts

The (auth) and (app) folder names are stripped from the URL — /login and /dashboard are the real routes. The layout inside each group applies only to its children.

Middleware for Route Protection

Middleware runs before every request, before your layout or page component renders. This makes it the right place to enforce authentication — redirect unauthenticated users to /login before they see a single byte of protected content.

// middleware.ts (at project root)
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({
    request: { headers: request.headers },
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { data: { user } } = await supabase.auth.getUser()

  const isProtected = request.nextUrl.pathname.startsWith('/dashboard') ||
                      request.nextUrl.pathname.startsWith('/settings')

  if (isProtected && !user) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  if (user && request.nextUrl.pathname === '/login') {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Critical detail: always use supabase.auth.getUser() in middleware, never supabase.auth.getSession(). The getSession() method reads from the cookie without re-validating with Supabase's servers — it is not safe for access control decisions. getUser() makes a network call to verify the JWT, which is what you want at the gate.

Supabase Setup: Two Clients for Two Contexts

Supabase provides two clients for Next.js: one for Server Components / Route Handlers / Server Actions (createServerClient from @supabase/ssr), and one for Client Components (createBrowserClient). They are not interchangeable.

The server client reads cookies from the incoming request headers and writes cookies to the response. The browser client stores the session in the browser's cookie jar automatically. Mixing them up causes auth state to desync in ways that are hard to debug.

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          )
        },
      },
    }
  )
}

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

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

Use the server client in any async Server Component, layout, or Route Handler. Use the browser client only inside "use client" components that need to react to live auth state changes (e.g., a user menu that shows/hides based on session).

Row Level Security: Your Last Line of Data Defense

Row Level Security (RLS) is what prevents one user from reading another user's data even if your application code has a bug. Without RLS, a Supabase query like supabase.from('projects').select('*') returns every row in the table — including rows belonging to other users — when called with the anon key. RLS adds a database-level WHERE clause to every query automatically.

Enable RLS on every user-data table immediately after creating it:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can only see their own projects"
  ON projects FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can insert their own projects"
  ON projects FOR INSERT
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update their own projects"
  ON projects FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can delete their own projects"
  ON projects FOR DELETE
  USING (auth.uid() = user_id);

The auth.uid() function returns the UUID of the currently authenticated Supabase user from the JWT in the request. It is evaluated per row, per query, at the database layer — no application code can bypass it when using the anon key.

Groq AI Integration: Server-Side Only

Groq's API key must never appear in client-side code. If you call Groq from a "use client" component, the key is visible in the browser's Network tab and in your JavaScript bundle — anyone can extract it and use your account. All AI calls belong in Server Components, Route Handlers, or Server Actions.

// lib/ai.ts
import Groq from 'groq-sdk'

const groq = new Groq({
  apiKey: process.env.GROQ_API_KEY, // server-only env var (no NEXT_PUBLIC_ prefix)
})

export async function generateProjectSummary(
  projectDescription: string,
  projectData: Record<string, unknown>
): Promise<string> {
  try {
    const completion = await groq.chat.completions.create({
      model: 'llama-3.3-70b-versatile',
      messages: [
        {
          role: 'system',
          content: `You are a technical project analyst. Given a project description and data,
write a concise 2-3 sentence executive summary. Be factual and specific.
Do not add information not present in the input. Output plain text only.`,
        },
        {
          role: 'user',
          content: `Project: ${projectDescription}\nData: ${JSON.stringify(projectData)}`,
        },
      ],
      temperature: 0.3,
      max_tokens: 256,
    })
    return completion.choices[0]?.message?.content ?? 'Summary unavailable.'
  } catch (error) {
    console.error('Groq AI error:', error)
    throw new Error('Failed to generate summary. Please try again.')
  }
}

Note that GROQ_API_KEY does not have the NEXT_PUBLIC_ prefix. Any environment variable without that prefix is unavailable to browser-side code — Next.js will simply treat it as undefined in the client bundle. This is your enforced separation between public and secret configuration.

The system prompt is opinionated and specific. Vague system prompts produce vague outputs. Instruct the model exactly what format you want, what constraints to follow, and what to omit. The temperature: 0.3 setting produces more deterministic, factual outputs — appropriate for a data summary task. Creative writing tasks benefit from higher temperatures (0.7–1.0).

API Route Handler with Auth + Groq

// app/api/summarize/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { generateProjectSummary } from '@/lib/ai'

export async function POST(request: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { projectId } = await request.json()

  const { data: project, error } = await supabase
    .from('projects')
    .select('description, metadata')
    .eq('id', projectId)
    .single()

  if (error || !project) {
    return NextResponse.json({ error: 'Project not found' }, { status: 404 })
  }

  // RLS ensures only the owner's project is returned above.
  // If another user's projectId was passed, the query returns null.
  const summary = await generateProjectSummary(
    project.description,
    project.metadata
  )

  return NextResponse.json({ summary })
}

Vercel Deployment and Environment Variables

Vercel deployment for a Next.js + Supabase app requires exactly the right set of environment variables configured per environment (Production, Preview, Development). In the Vercel dashboard under Settings → Environment Variables, add:

  • NEXT_PUBLIC_SUPABASE_URL — your Supabase project URL (safe to expose)
  • NEXT_PUBLIC_SUPABASE_ANON_KEY — your Supabase anon key (safe to expose, protected by RLS)
  • SUPABASE_SERVICE_ROLE_KEY — only for admin/migration scripts, never in browser code
  • GROQ_API_KEY — server-only, never prefix with NEXT_PUBLIC_

Vercel automatically injects environment variables into your build and runtime. For Preview deployments (every pull request gets its own URL), you can either share the same Supabase project or create a separate Supabase branch project. The latter is better for production teams — it prevents pull request testing from polluting your production database.

One common pitfall: Vercel caches builds aggressively. If you change an environment variable and redeploy, the change takes effect immediately on the server, but if your app uses next/headers cached responses, you may need to purge the cache manually. Use revalidatePath() or revalidateTag() in Server Actions when data changes to trigger cache invalidation correctly.