{ }
Published on

Complete Guide: Nuxt 3 + Supabase Auth (2025 Edition)

Authors
  • avatar
    Name
    Ahmed Farid
    Twitter
    @

TIP

Supabase brings Firebase-like auth to Postgres. With Nuxt 3 you get SSR, file-based routing, and composables—perfect match for modern full-stack Vue apps.

This guide covers:

  1. Project scaffolding with Nuxt 3 & TypeScript.
  2. Supabase project + environment variables.
  3. Client-side sign-in / magic link / social OAuth.
  4. Secure SSR session cookies on server routes.
  5. Global Pinia auth store with auto-refresh.
  6. Route middleware for protected pages.
  7. Production deployment to Vercel.

Table of Contents

1. Prerequisites & Terminology

  • Node.js 20.
  • Supabase account (free tier).
  • Familiarity with Vue 3, Composition API.
TermMeaning
JWTJSON Web Token returned by Supabase session
Refresh tokenToken used to renew JWT silently
Server-side renderingHTML generated on server for SEO and auth cookies

2. Create Nuxt 3 Project & Install Packages

npx nuxi init nuxt-supa
cd nuxt-supa
yarn install
yarn add @supabase/supabase-js@2 @vueuse/core pinia

Enable Pinia in nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})

3. Add Environment Variables

.env (never commit):

SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_ANON_KEY=ey...

Nuxt auto-injects NITRO_PUBLIC_ prefix; we can expose safely:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      supabaseUrl: process.env.SUPABASE_URL,
      supabaseKey: process.env.SUPABASE_ANON_KEY,
    },
  },
})

4. Create Supabase Client Composable

composables/useSupabase.ts:

import { createClient } from '@supabase/supabase-js'
export const useSupabase = () => {
  const config = useRuntimeConfig()
  const supabase = createClient(config.public.supabaseUrl, config.public.supabaseKey, {
    auth: { persistSession: false },
  })
  return supabase
}

We disable local storage because we’ll store JWT in httpOnly cookie.

5. Pinia Auth Store

stores/auth.ts:

import { defineStore } from 'pinia'
import { useSupabase } from '@/composables/useSupabase'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<null | any>(null)
  const supabase = useSupabase()

  async function signInEmail(email: string) {
    const { error } = await supabase.auth.signInWithOtp({ email })
    if (error) throw error
  }

  async function signInOAuth(provider: 'github' | 'google') {
    const { error } = await supabase.auth.signInWithOAuth({
      provider,
      options: { redirectTo: `${location.origin}/confirm` },
    })
    if (error) throw error
  }

  async function signOut() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
  }

  return { user, signInEmail, signInOAuth, signOut }
})

6. Server API Routes for Session Cookies

server/api/auth/callback.post.ts:

import { serverSupabaseClient } from '#supabase/server'
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  // body contains {access_token, refresh_token, expires_in, token_type, provider_token}
  setCookie(event, 'sb-access-token', body.access_token, {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: body.expires_in,
  })
  setCookie(event, 'sb-refresh-token', body.refresh_token, {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  })
  return 'ok'
})

For logout:

export default defineEventHandler((event) => {
  deleteCookie(event, 'sb-access-token')
  deleteCookie(event, 'sb-refresh-token')
  return 'signed out'
})

7. Middleware to Protect Routes

middleware/auth.global.ts:

export default defineNuxtRouteMiddleware(async (to) => {
  if (!to.meta.requiresAuth) return
  const { data } = await useFetch('/api/user')
  if (!data.value?.id) return navigateTo('/login')
})

Apply in page:

<script setup>
definePageMeta({ requiresAuth: true })
</script>

8. Auto-Refresh on Server Side

server/api/user.get.ts:

import jwt from 'jsonwebtoken'
import { createClient } from '@supabase/supabase-js'
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const access = getCookie(event, 'sb-access-token')
  const refresh = getCookie(event, 'sb-refresh-token')
  if (!access || !refresh) return null
  try {
    jwt.verify(access, '', { algorithms: ['HS256'] })
    return jwt.decode(access)
  } catch {
    // expired – refresh
    const supabase = createClient(config.public.supabaseUrl, config.public.supabaseKey)
    const { data, error } = await supabase.auth.setSession({
      access_token: access,
      refresh_token: refresh,
    })
    if (error) return null
    setCookie(event, 'sb-access-token', data.session!.access_token, {
      httpOnly: true,
      sameSite: 'lax',
      maxAge: data.session!.expires_in,
    })
    return data.user
  }
})

9. UI Components

  • LoginForm.vue with email input -> auth.signInEmail.
  • Callback.vue reads code & state from URL, posts to /api/auth/callback, then redirects.
  • NavBar shows user avatar & sign-out.

10. Supabase Remote Config (Row Level Security)

Enable RLS on profiles table:

alter table profiles enable row level security;
create policy "Individuals can view their profile" on profiles for select using (auth.uid() = id);

11. Deployment to Vercel

vercel.json:

{
  "env": {
    "SUPABASE_URL": "https://xxxx.supabase.co",
    "SUPABASE_ANON_KEY": "..."
  }
}

Build command npm run build, output .output for Nitro.

12. Troubleshooting

IssueFix
AuthApiError: Invalid loginEnsure redirect URL whitelisted in Supabase dashboard
Cookies missing on SSRAdd domain & secure flags in setCookie during production
Infinite redirectCheck route middleware & user API response

13. Further Reading & Resources

  • Nuxt Auth best practices doc.
  • Supabase Auth helpers for Nuxt (not yet stable in 2025).
  • RFC: Server sessions in Nuxt 3.

14. Conclusion

You now have full email, magic link, and social login powered by Supabase, integrated with Nuxt 3’s SSR and Pinia state. Extend with database CRUD, storage uploads, and edge functions to build a robust Vue SaaS! 🚀