Supabase with Vue.js: Complete Integration Tutorial

Integrating Supabase with Vue.js 3 and Composition API enables building full-stack applications with reactive authentication, real-time database subscriptions, and type-safe queries leveraging Vue's reactivity system for automatic UI updates when data changes. Unlike traditional REST APIs requiring manual state management and polling, Supabase provides real-time subscriptions, built-in authentication, and composable utilities fitting perfectly with Vue's Composition API patterns creating seamless developer experiences. This comprehensive guide covers setting up Supabase in Vue 3 projects, creating composables for authentication, building reactive database queries with computed properties, implementing real-time subscriptions with automatic cleanup, handling form submissions and validation, managing global state with Pinia, integrating with Vue Router for protected routes, and deploying Vue + Supabase applications. Vue.js integration becomes ideal for developers preferring Vue's progressive framework, building SPAs with reactive UIs, creating admin dashboards, or migrating from Vue 2 to modern Vue 3 Composition API patterns. Before proceeding, understand JavaScript client basics, authentication, and real-time features.
Vue 3 Project Setup
# Create Vue 3 project with Vite
npm create vite@latest my-vue-app -- --template vue
cd my-vue-app
# Install dependencies
npm install
npm install @supabase/supabase-js
# Optional: TypeScript support
npm install -D typescript @vue/tsconfig
# Optional: Pinia for state management
npm install pinia
# Optional: Vue Router
npm install vue-router@4
# Project structure:
# my-vue-app/
# src/
# lib/
# supabase.js # Supabase client
# composables/
# useAuth.js # Authentication composable
# useDatabase.js # Database composable
# components/
# Auth.vue
# PostsList.vue
# views/
# Home.vue
# Dashboard.vue
# App.vue
# main.jsSupabase Client Configuration
// src/lib/supabase.js
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)
// .env file
// VITE_SUPABASE_URL=your-project-url
// VITE_SUPABASE_ANON_KEY=your-anon-keyAuthentication Composable
// src/composables/useAuth.js
import { ref, onMounted } from 'vue'
import { supabase } from '../lib/supabase'
export function useAuth() {
const user = ref(null)
const loading = ref(true)
onMounted(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
user.value = session?.user ?? null
loading.value = false
})
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
user.value = session?.user ?? null
loading.value = false
}
)
// Cleanup subscription on unmount
return () => subscription.unsubscribe()
})
const signUp = async (email, password) => {
const { data, error } = await supabase.auth.signUp({
email,
password
})
return { data, error }
}
const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})
return { data, error }
}
const signOut = async () => {
const { error } = await supabase.auth.signOut()
return { error }
}
const signInWithGoogle = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google'
})
return { data, error }
}
return {
user,
loading,
signUp,
signIn,
signOut,
signInWithGoogle
}
}Authentication Component
<!-- src/components/Auth.vue -->
<template>
<div class="auth-container">
<div v-if="loading">Loading...</div>
<div v-else-if="user">
<h2>Welcome, {{ user.email }}</h2>
<button @click="handleSignOut">Sign Out</button>
</div>
<div v-else class="auth-forms">
<h2>{{ isSignUp ? 'Sign Up' : 'Sign In' }}</h2>
<form @submit.prevent="handleSubmit">
<input
v-model="email"
type="email"
placeholder="Email"
required
/>
<input
v-model="password"
type="password"
placeholder="Password"
required
/>
<button type="submit">
{{ isSignUp ? 'Sign Up' : 'Sign In' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<button @click="isSignUp = !isSignUp">
{{ isSignUp ? 'Already have an account?' : "Don't have an account?" }}
</button>
<button @click="handleGoogleSignIn">
Sign in with Google
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuth } from '../composables/useAuth'
const { user, loading, signUp, signIn, signOut, signInWithGoogle } = useAuth()
const email = ref('')
const password = ref('')
const isSignUp = ref(false)
const error = ref(null)
async function handleSubmit() {
error.value = null
const { error: authError } = isSignUp.value
? await signUp(email.value, password.value)
: await signIn(email.value, password.value)
if (authError) {
error.value = authError.message
} else {
email.value = ''
password.value = ''
}
}
async function handleSignOut() {
await signOut()
}
async function handleGoogleSignIn() {
await signInWithGoogle()
}
</script>
<style scoped>
.auth-container {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
input, button {
padding: 0.5rem;
}
.error {
color: red;
}
</style>Database Query Composable
// src/composables/useDatabase.js
import { ref, onMounted, onUnmounted } from 'vue'
import { supabase } from '../lib/supabase'
export function usePosts() {
const posts = ref([])
const loading = ref(true)
const error = ref(null)
async function fetchPosts() {
loading.value = true
error.value = null
const { data, error: fetchError } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (fetchError) {
error.value = fetchError.message
} else {
posts.value = data
}
loading.value = false
}
async function createPost(title, content) {
const { data, error: createError } = await supabase
.from('posts')
.insert({ title, content })
.select()
.single()
if (!createError) {
posts.value.unshift(data)
}
return { data, error: createError }
}
async function updatePost(id, updates) {
const { data, error: updateError } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
.select()
.single()
if (!updateError) {
const index = posts.value.findIndex(p => p.id === id)
if (index !== -1) {
posts.value[index] = data
}
}
return { data, error: updateError }
}
async function deletePost(id) {
const { error: deleteError } = await supabase
.from('posts')
.delete()
.eq('id', id)
if (!deleteError) {
posts.value = posts.value.filter(p => p.id !== id)
}
return { error: deleteError }
}
onMounted(() => {
fetchPosts()
})
return {
posts,
loading,
error,
fetchPosts,
createPost,
updatePost,
deletePost
}
}Real-time Subscriptions
// src/composables/useRealtimePosts.js
import { ref, onMounted, onUnmounted } from 'vue'
import { supabase } from '../lib/supabase'
export function useRealtimePosts() {
const posts = ref([])
const loading = ref(true)
let subscription = null
async function fetchPosts() {
const { data } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
posts.value = data || []
loading.value = false
}
function setupSubscription() {
subscription = supabase
.channel('posts-channel')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
(payload) => {
posts.value.unshift(payload.new)
}
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'posts' },
(payload) => {
const index = posts.value.findIndex(p => p.id === payload.new.id)
if (index !== -1) {
posts.value[index] = payload.new
}
}
)
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'posts' },
(payload) => {
posts.value = posts.value.filter(p => p.id !== payload.old.id)
}
)
.subscribe()
}
onMounted(() => {
fetchPosts()
setupSubscription()
})
onUnmounted(() => {
if (subscription) {
supabase.removeChannel(subscription)
}
})
return { posts, loading }
}Posts List Component
<!-- src/components/PostsList.vue -->
<template>
<div class="posts-container">
<h2>Posts</h2>
<!-- Create post form -->
<form @submit.prevent="handleCreatePost" class="create-form">
<input
v-model="newTitle"
placeholder="Post title"
required
/>
<textarea
v-model="newContent"
placeholder="Post content"
required
></textarea>
<button type="submit" :disabled="creating">
{{ creating ? 'Creating...' : 'Create Post' }}
</button>
</form>
<!-- Loading state -->
<div v-if="loading" class="loading">Loading posts...</div>
<!-- Error state -->
<div v-if="error" class="error">{{ error }}</div>
<!-- Posts list -->
<div v-else class="posts-list">
<article
v-for="post in posts"
:key="post.id"
class="post-card"
>
<div v-if="editingId === post.id">
<input v-model="editTitle" />
<textarea v-model="editContent"></textarea>
<button @click="handleUpdate(post.id)">Save</button>
<button @click="editingId = null">Cancel</button>
</div>
<div v-else>
<h3>{{ post.title }}</h3>
<p>{{ post.content }}</p>
<div class="actions">
<button @click="startEdit(post)">Edit</button>
<button @click="handleDelete(post.id)">Delete</button>
</div>
</div>
</article>
<div v-if="posts.length === 0" class="empty">
No posts yet. Create one!
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { usePosts } from '../composables/useDatabase'
const {
posts,
loading,
error,
createPost,
updatePost,
deletePost
} = usePosts()
const newTitle = ref('')
const newContent = ref('')
const creating = ref(false)
const editingId = ref(null)
const editTitle = ref('')
const editContent = ref('')
async function handleCreatePost() {
creating.value = true
await createPost(newTitle.value, newContent.value)
newTitle.value = ''
newContent.value = ''
creating.value = false
}
function startEdit(post) {
editingId.value = post.id
editTitle.value = post.title
editContent.value = post.content
}
async function handleUpdate(id) {
await updatePost(id, {
title: editTitle.value,
content: editContent.value
})
editingId.value = null
}
async function handleDelete(id) {
if (confirm('Delete this post?')) {
await deletePost(id)
}
}
</script>
<style scoped>
.posts-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.create-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-card {
border: 1px solid #ddd;
padding: 1rem;
border-radius: 8px;
}
.actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
</style>Protected Routes with Vue Router
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { supabase } from '../lib/supabase'
import Home from '../views/Home.vue'
import Dashboard from '../views/Dashboard.vue'
import Login from '../views/Login.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach(async (to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth) {
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
} else {
next()
}
})
export default routerVue + Supabase Best Practices
- Use Composables: Create reusable composables for auth, database queries, and real-time subscriptions
- Cleanup Subscriptions: Always use onUnmounted to cleanup real-time subscriptions preventing memory leaks
- Reactive State: Leverage Vue's ref and reactive for automatic UI updates when data changes
- Error Handling: Use ref for error states and display user-friendly messages
- Loading States: Show loading indicators during async operations for better UX
- Route Protection: Use router guards to protect authenticated routes
- TypeScript Support: Add TypeScript for type safety similar to React TypeScript integration
Next Steps
- Add TypeScript: Migrate to TypeScript for type safety
- Compare Frameworks: Explore React or Next.js integrations
- Learn Authentication: Deep dive into email/password auth
- Secure Access: Implement Row Level Security policies
Conclusion
Integrating Supabase with Vue.js 3 Composition API enables building full-stack reactive applications with authentication, real-time database subscriptions, and composable utilities leveraging Vue's reactivity system for automatic UI updates. By creating reusable composables for authentication with user state and auth methods, database queries with CRUD operations, and real-time subscriptions with automatic cleanup, you build maintainable Vue applications. Vue's reactive refs automatically update UI when Supabase data changes, composables encapsulate Supabase logic keeping components clean, and onUnmounted lifecycle hooks ensure proper subscription cleanup preventing memory leaks. Always use composables for reusable logic, cleanup subscriptions in onUnmounted, leverage Vue reactivity for state management, handle errors gracefully with user feedback, protect routes with router guards, and consider TypeScript for additional safety. Vue + Supabase integration provides excellent developer experience combining Vue's progressive framework with Supabase's backend services creating modern full-stack applications. Continue building with Next.js for SSR, TypeScript types, and security policies.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


