$ cat /posts/supabase-with-vuejs-complete-integration-tutorial.md
[tags]Supabase

Supabase with Vue.js: Complete Integration Tutorial

drwxr-xr-x2026-01-265 min0 views
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

bashsetup.sh
# 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.js

Supabase Client Configuration

javascriptsupabase.js
// 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-key

Authentication Composable

javascriptuseAuth.js
// 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

vueAuth.vue
<!-- 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

javascriptuseDatabase.js
// 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

javascriptuseRealtimePosts.js
// 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

vuePostsList.vue
<!-- 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

javascriptrouter.js
// 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 router

Vue + 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
Pro Tip: Vue's Composition API pairs perfectly with Supabase composables. Create a composables/ folder for all Supabase logic keeping components clean and focused on UI. Combine with real-time subscriptions for live updating UIs.

Next Steps

  1. Add TypeScript: Migrate to TypeScript for type safety
  2. Compare Frameworks: Explore React or Next.js integrations
  3. Learn Authentication: Deep dive into email/password auth
  4. 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.

$ cat /comments/ (0)

new_comment.sh

// Email hidden from public

>_

$ cat /comments/

// No comments found. Be the first!

[session] guest@{codershandbook}[timestamp] 2026

Navigation

Categories

Connect

Subscribe

// 2026 {Coders Handbook}. EOF.