SStackforge
← Back to all posts

How to Connect Supabase to a Next.js App (and Deploy on Vercel)

buildinpublicsupabasenextjsvercel

Prerequisites

  • A Supabase project
  • A Next.js App Router project
  • Node.js 18+

Step 1: Install dependencies

npm install @supabase/supabase-js @supabase/ssr

Step 2: Create the lib/supabase trio

A Next.js App Router project needs three different Supabase clients: one for the browser, one for the server, and one for middleware.

lib/supabase/client.ts — for the browser

import { createBrowserClient } from '@supabase/ssr'

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

lib/supabase/server.ts — for the server

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) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component — safe to ignore
          }
        },
      },
    }
  )
}

lib/supabase/middleware.ts — session refresh

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

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

  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 }) =>
            request.cookies.set(name, value)
          )
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  await supabase.auth.getUser()
  return response
}

Step 3: Configure .env.local

Create a .env.local file at the root of your project and add your Supabase URL and publishable key:

NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-publishable-key
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

Notes:

  • .env.local must be in .gitignore — never commit it
  • The publishable key is safe to expose to the browser (it is designed to be public)
  • The secret key (service_role) must never be prefixed with NEXT_PUBLIC_. It is server-side only.

Step 4: Read data in a Server Component

After setting up the clients, I replaced hard-coded data in my vocal practice app with real Supabase data.

Result: the web version now shows the exact same data as the existing mobile app — verified 1:1.

Data fetch helper

import { createClient } from '@/lib/supabase/server'

export async function getUnits(lang = 'cn') {
  const supabase = await createClient()
  const { data, error } = await supabase
    .from('unit_lang')
    .select('*')
    .eq('lang', lang)
    .order('unit_num')

  if (error) return []
  return data ?? []
}

Page usage

// app/page.tsx
import { getUnits } from '@/lib/data/units'
import DashboardContent from '@/components/DashboardContent'

export default async function HomePage() {
  const units = await getUnits('cn')
  return <DashboardContent units={units} />
}

Client-side filtering by day

The days field is a comma-separated string (e.g. "1,2,3"), so filtering happens on the client:

const currentUnits = units.filter((unit) => {
  const dayArray = unit.days.split(',').map((d) => Number(d.trim()))
  return dayArray.includes(activeDay)
})

Key takeaways

  • days is a comma-separated string ("1,2,3") — use split + includes to filter
  • need_pay = 0 means free, need_pay = 1 means VIP
  • Duration is 0 for now (real durations live in the part table — next migration)

Commit and push