Use SupaHost with Nuxt

Connect a Nuxt app to a SupaHost project with @supabase/ssr so the session lives in cookies. Runtime config, a browser plugin, a server helper, server routes and email sign-in.

Nuxt renders pages on the server and hydrates them in the browser, so both sides need to know who the user is. @supabase/ssr stores the Supabase session in cookies, which the browser sends with every request, and gives you a client for each side.

The examples use the recipes table from the quickstart.

Install

npx nuxi@latest init recipe-box
cd recipe-box
npm install @supabase/supabase-js @supabase/ssr

Add your project keys

Declare the values in runtime config so Nuxt can fill them from the environment:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      supabaseUrl: '',
      supabaseAnonKey: '',
    },
  },
})

Then set them in .env, using the Project URL and anon key from the Connect tab (subtab App):

NUXT_PUBLIC_SUPABASE_URL=https://app-one.acme.supahost.space
NUXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

Anything under public is sent to the browser. If server code needs the service_role key, put it in a non-public key such as runtimeConfig.supabaseServiceRoleKey (env NUXT_SUPABASE_SERVICE_ROLE_KEY) and only read it in server/.

A client for the browser

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

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()
  const supabase = createBrowserClient(
    config.public.supabaseUrl,
    config.public.supabaseAnonKey
  )
  return { provide: { supabase } }
})

The .client.ts suffix keeps it out of server rendering. Components get it with const { $supabase } = useNuxtApp().

A client for the server

Server code creates a fresh client per request from that request's cookies:

// server/utils/supabase.ts
import { createServerClient } from '@supabase/ssr'
import type { H3Event } from 'h3'

export function serverSupabase(event: H3Event) {
  const config = useRuntimeConfig(event)

  return createServerClient(config.public.supabaseUrl, config.public.supabaseAnonKey, {
    cookies: {
      getAll() {
        return Object.entries(parseCookies(event)).map(([name, value]) => ({ name, value }))
      },
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value, options }) =>
          setCookie(event, name, value, options)
        )
      },
    },
  })
}

Files in server/utils are auto-imported in server routes, as are parseCookies and setCookie.

Read data on the server

// server/api/recipes.get.ts
export default defineEventHandler(async (event) => {
  const supabase = serverSupabase(event)
  const { data, error } = await supabase
    .from('recipes')
    .select('id, title, minutes')
    .order('created_at', { ascending: false })

  if (error) throw createError({ statusCode: 500, statusMessage: error.message })
  return data
})
<!-- pages/index.vue -->
<script setup lang="ts">
const { data: recipes } = await useFetch('/api/recipes', {
  headers: useRequestHeaders(['cookie']),
})
</script>

<template>
  <ul>
    <li v-for="r in recipes" :key="r.id">{{ r.title }} ({{ r.minutes }} min)</li>
  </ul>
</template>

Passing the cookie header matters during server rendering: without it, the API route runs as a signed-out visitor.

Sign users in

Sign in from the browser client. It writes the session cookies, so the next server request is authenticated too.

<!-- pages/login.vue -->
<script setup lang="ts">
const { $supabase } = useNuxtApp()
const email = ref('')
const password = ref('')
const message = ref('')

async function signIn() {
  const { error } = await $supabase.auth.signInWithPassword({
    email: email.value,
    password: password.value,
  })
  if (error) message.value = error.message
  else await navigateTo('/')
}

async function signUp() {
  const { error } = await $supabase.auth.signUp({
    email: email.value,
    password: password.value,
    options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
  })
  message.value = error ? error.message : 'Check your inbox to confirm your email.'
}
</script>

<template>
  <form @submit.prevent="signIn">
    <input v-model="email" type="email" required />
    <input v-model="password" type="password" required />
    <button type="submit">Sign in</button>
    <button type="button" @click="signUp">Create account</button>
    <p>{{ message }}</p>
  </form>
</template>

The confirmation email returns to /auth/callback with a code. A server route exchanges it for a session:

// server/routes/auth/callback.get.ts
export default defineEventHandler(async (event) => {
  const { code } = getQuery(event)
  if (typeof code === 'string') {
    const { error } = await serverSupabase(event).auth.exchangeCodeForSession(code)
    if (!error) return sendRedirect(event, '/')
  }
  return sendRedirect(event, '/login?failed=1')
})

To check the user in a server route, call await serverSupabase(event).auth.getUser(). It confirms the token with Auth, unlike getSession(), which only reads the cookie.

Allow your redirect URLs

On the project's Auth tab, set Site URL to your production address and list other origins under Redirect URLs, then click Save Auth settings:

http://localhost:3000/**
https://recipes.example.com/**

Using the @nuxtjs/supabase module instead

The community module @nuxtjs/supabase wraps the same @supabase/ssr setup and adds composables such as useSupabaseClient(). It works with SupaHost: set SUPABASE_URL to your project URL and SUPABASE_KEY to the anon key. Check its redirect option, which by default sends signed-out visitors to a login page.

Next steps

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us