# How to Use Supabase with Vue.js

- Tool: Supabase
- Difficulty: Beginner
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+, Vue 3 with Composition API
- Last updated: March 2026

## TL;DR

To use Supabase with Vue.js, install @supabase/supabase-js, create a client instance with your project URL and anon key, and wrap it in a Vue composable for reuse across components. You can then perform CRUD operations, listen for auth state changes, and subscribe to real-time updates using the Composition API. Store your keys in environment variables prefixed with VITE_ for Vite-based projects.

## Integrating Supabase with Vue.js Using the Composition API

Supabase pairs naturally with Vue.js because both emphasize simplicity and developer experience. This tutorial shows you how to set up the Supabase JavaScript client in a Vue 3 project, create a composable that any component can import, and build a working CRUD interface with authentication. You will also learn how to manage environment variables in a Vite-based Vue project.

## Before you start

- A Supabase project with at least one table created
- A Vue 3 project scaffolded with Vite (npm create vue@latest)
- Node.js 18+ installed
- Your Supabase project URL and anon key from the Dashboard (Settings > API)

## Step-by-step guide

### 1. Install the Supabase JavaScript client

Add the Supabase JS client library to your Vue project. This is the same package used with React, Svelte, or any JavaScript framework. It provides methods for auth, database queries, storage, and real-time subscriptions.

```
npm install @supabase/supabase-js
```

**Expected result:** @supabase/supabase-js is added to your package.json dependencies.

### 2. Configure environment variables for Vite

Create a .env.local file in your project root and add your Supabase URL and anon key. In Vite-based projects, environment variables must be prefixed with VITE_ to be accessible in client-side code. The anon key is safe to use in the browser because it respects Row Level Security policies. Never expose your service role key in frontend code.

```
# .env.local
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

**Expected result:** Environment variables are available via import.meta.env.VITE_SUPABASE_URL in your Vue code.

### 3. Create the Supabase client instance

Create a dedicated file to initialize the Supabase client. This file exports a single client instance that is reused across your entire app. Placing it in src/lib/ keeps it organized and importable from any component or composable.

```
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)
```

**Expected result:** A single Supabase client instance is exported and ready to use throughout the app.

### 4. Build a Vue composable for data fetching

Create a composable that wraps Supabase queries with Vue's reactive refs. This pattern follows Vue 3 best practices and makes your data fetching logic reusable. The composable returns reactive data, loading state, and an error ref that your template can bind to directly.

```
// src/composables/useTodos.ts
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase'

export function useTodos() {
  const todos = ref<any[]>([])
  const loading = ref(true)
  const error = ref<string | null>(null)

  async function fetchTodos() {
    loading.value = true
    const { data, error: err } = await supabase
      .from('todos')
      .select('*')
      .order('created_at', { ascending: false })

    if (err) {
      error.value = err.message
    } else {
      todos.value = data ?? []
    }
    loading.value = false
  }

  async function addTodo(task: string) {
    const { error: err } = await supabase
      .from('todos')
      .insert({ task, is_complete: false })

    if (err) {
      error.value = err.message
    } else {
      await fetchTodos()
    }
  }

  onMounted(fetchTodos)

  return { todos, loading, error, fetchTodos, addTodo }
}
```

**Expected result:** The composable returns reactive data that updates the template automatically when queries complete.

### 5. Use the composable in a Vue component

Import the composable into a Vue component and bind the reactive refs to your template. The composable handles all the Supabase logic, keeping your component clean and focused on presentation. Use v-for to render the list and v-model for the input.

```
<script setup lang="ts">
import { ref } from 'vue'
import { useTodos } from '@/composables/useTodos'

const { todos, loading, error, addTodo } = useTodos()
const newTask = ref('')

async function handleSubmit() {
  if (newTask.value.trim()) {
    await addTodo(newTask.value.trim())
    newTask.value = ''
  }
}
</script>

<template>
  <div>
    <form @submit.prevent="handleSubmit">
      <input v-model="newTask" placeholder="Add a task..." />
      <button type="submit">Add</button>
    </form>
    <p v-if="loading">Loading...</p>
    <p v-if="error" class="error">{{ error }}</p>
    <ul>
      <li v-for="todo in todos" :key="todo.id">{{ todo.task }}</li>
    </ul>
  </div>
</template>
```

**Expected result:** The component renders a list of todos fetched from Supabase and allows adding new items.

### 6. Add authentication with onAuthStateChange

Listen for auth state changes in your App.vue or a dedicated auth composable. The onAuthStateChange listener fires whenever the user logs in, logs out, or their token refreshes. Use this to update a reactive user ref that your components can check for conditional rendering.

```
// src/composables/useAuth.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'

export function useAuth() {
  const user = ref<User | null>(null)
  let subscription: { unsubscribe: () => void } | null = null

  onMounted(() => {
    supabase.auth.getUser().then(({ data }) => {
      user.value = data.user
    })

    const { data } = supabase.auth.onAuthStateChange((event, session) => {
      user.value = session?.user ?? null
    })
    subscription = data.subscription
  })

  onUnmounted(() => {
    subscription?.unsubscribe()
  })

  return { user }
}
```

**Expected result:** The user ref updates reactively whenever the authentication state changes.

## Complete code example

File: `src/lib/supabase.ts + src/composables/useTodos.ts`

```typescript
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

// src/composables/useTodos.ts
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase'

export function useTodos() {
  const todos = ref<any[]>([])
  const loading = ref(true)
  const error = ref<string | null>(null)

  async function fetchTodos() {
    loading.value = true
    const { data, error: err } = await supabase
      .from('todos')
      .select('*')
      .order('created_at', { ascending: false })
    if (err) {
      error.value = err.message
    } else {
      todos.value = data ?? []
    }
    loading.value = false
  }

  async function addTodo(task: string) {
    // RLS must allow INSERT for authenticated users
    const { error: err } = await supabase
      .from('todos')
      .insert({ task, is_complete: false })
    if (err) {
      error.value = err.message
    } else {
      await fetchTodos()
    }
  }

  async function toggleTodo(id: number, isComplete: boolean) {
    // RLS must allow UPDATE for the record owner
    const { error: err } = await supabase
      .from('todos')
      .update({ is_complete: !isComplete })
      .eq('id', id)
    if (err) {
      error.value = err.message
    } else {
      await fetchTodos()
    }
  }

  async function deleteTodo(id: number) {
    // RLS must allow DELETE for the record owner
    const { error: err } = await supabase
      .from('todos')
      .delete()
      .eq('id', id)
    if (err) {
      error.value = err.message
    } else {
      await fetchTodos()
    }
  }

  onMounted(fetchTodos)

  return { todos, loading, error, fetchTodos, addTodo, toggleTodo, deleteTodo }
}
```

## Common mistakes

- **Creating a new Supabase client inside each component instead of sharing a single instance** — undefined Fix: Create the client once in a dedicated file (e.g., src/lib/supabase.ts) and import it everywhere. Multiple client instances break session management.
- **Forgetting the VITE_ prefix on environment variables, causing them to be undefined at runtime** — undefined Fix: In Vite projects, only variables prefixed with VITE_ are exposed to client code. Use VITE_SUPABASE_URL, not SUPABASE_URL.
- **Not setting up RLS policies, resulting in empty query results with no error message** — undefined Fix: When RLS is enabled with no policies, all queries silently return empty arrays. Create appropriate SELECT, INSERT, UPDATE, and DELETE policies for your tables.

## Best practices

- Initialize the Supabase client once in a shared module and export it for use across all composables and components
- Use Vue composables to encapsulate Supabase logic, keeping components focused on presentation
- Always check for errors in Supabase responses — destructure { data, error } and handle both cases
- Store Supabase URL and anon key in VITE_ prefixed environment variables, never hardcode them
- Unsubscribe from onAuthStateChange and real-time listeners in onUnmounted to prevent memory leaks
- Use TypeScript with Supabase generated types (supabase gen types typescript) for full type safety in your composables
- Set up RLS policies for every table that your frontend accesses — the anon key is public and must be protected by RLS

## Frequently asked questions

### Can I use Supabase with Vue 2?

Yes, the @supabase/supabase-js package works with Vue 2, but you cannot use the Composition API composable pattern. Instead, create Supabase methods in your component's methods object and store data in the data() function.

### Do I need to use Vuex or Pinia with Supabase?

Not necessarily. For most apps, composables with reactive refs are sufficient. Use Pinia if you need to share Supabase data across many unrelated components or if your app state is complex enough to benefit from a centralized store.

### How do I handle real-time updates in Vue?

Use supabase.channel().on('postgres_changes', ...).subscribe() inside a composable with onMounted. When a change arrives, update the reactive ref. Remember to call supabase.removeChannel() in onUnmounted.

### Is the anon key safe to use in browser code?

Yes. The anon key is designed for client-side use. It maps to the anon Postgres role, which is restricted by Row Level Security policies. Without proper RLS policies, no data is accessible even with the key.

### Why does my query return an empty array instead of an error?

This is how RLS works in Supabase. When RLS is enabled but no policy grants access, queries return empty arrays silently. Check that you have a SELECT policy for the authenticated or anon role on your table.

### Can I use Supabase with Vue.js and TypeScript?

Yes. Run supabase gen types typescript --local to generate types from your database schema, then pass them as a generic to createClient for fully typed queries and responses.

### Can RapidDev help integrate Supabase into my Vue.js project?

Yes. RapidDev can set up your Supabase integration including auth flows, real-time features, RLS policies, and component architecture tailored to your Vue.js application.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-use-supabase-with-vue-js
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-use-supabase-with-vue-js
