When a founder says "we need to build an MVP and we don't have a stack chosen yet," here's what I recommend most of the time: Vue 3 + Nuxt 3 + Supabase + Tailwind, deployed on Vercel or Fly.io.
This isn't a religious position — there are situations where Firebase makes more sense, or where a custom backend is the right call. But for the majority of startup MVPs I see, this combination gets you to a deployed, scalable-enough product faster than anything else I've worked with.
Here's why, and how the pieces fit together.
Why this combination works
Each piece of the stack does one job well:
- Vue 3 + Nuxt 3: The frontend and application layer. File-based routing, auto-imports, hybrid rendering, server routes.
- Supabase: Postgres database, realtime, authentication, storage — all behind a clean API.
- Tailwind CSS: Utility-first styling that scales from prototype to production without a separate design system.
- Vercel / Fly.io: Hosting with zero-config deployments.
The key property of this stack is that it minimises the distance between "idea" and "deployed." You're not maintaining a separate backend service, not configuring infrastructure, not making decisions about ORM vs query builder before you've written any product code.
How Supabase connects to Nuxt
The integration is straightforward. Supabase provides a JavaScript client that works in both browser and server environments, which matters for Nuxt's hybrid rendering model.
// composables/useSupabase.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from '~/types/supabase'
const supabaseUrl = useRuntimeConfig().public.supabaseUrl
const supabaseKey = useRuntimeConfig().public.supabaseKey
export const supabase = createClient<Database>(supabaseUrl, supabaseKey)
For server-side operations (things that need the service role key, not the anon key), you use Nuxt's server routes:
// server/api/admin/users.get.ts
import { createClient } from '@supabase/supabase-js'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const adminClient = createClient(
config.supabaseUrl,
config.supabaseServiceKey
)
const { data, error } = await adminClient.auth.admin.listUsers()
if (error) throw createError({ statusCode: 500, message: error.message })
return data.users
})
This pattern keeps privileged operations server-side while letting client-side code use the anon key safely with Row Level Security.
Authentication
Supabase Auth is one of the cleanest auth implementations I've worked with. Email/password, magic links, and OAuth (Google, GitHub, LinkedIn) all work out of the box.
The auth state is reactive in Vue:
// composables/useAuth.ts
export function useAuth() {
const user = ref(null)
const loading = ref(true)
supabase.auth.onAuthStateChange((event, session) => {
user.value = session?.user ?? null
loading.value = false
})
const signIn = (email: string, password: string) =>
supabase.auth.signInWithPassword({ email, password })
const signOut = () => supabase.auth.signOut()
return { user, loading, signIn, signOut }
}
For route protection in Nuxt, a middleware handles auth checks:
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const { user } = useAuth()
if (!user.value) {
return navigateTo('/login')
}
})
Row Level Security: the missing piece most tutorials skip
The most important Supabase concept that most tutorials don't emphasise enough: Row Level Security (RLS).
By default, Supabase tables are accessible to anyone with your anon key. That means your frontend can accidentally expose all user data if you're not careful. RLS lets you define policies at the database level that control what each user can read and write.
A typical policy for user-specific data:
-- Users can only read their own data
CREATE POLICY "Users can read own data"
ON public.tasks
FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert their own data
CREATE POLICY "Users can insert own data"
ON public.tasks
FOR INSERT
WITH CHECK (auth.uid() = user_id);
This runs in the database, not in your application code. Even if there's a bug in your frontend that tries to read another user's data, Postgres refuses it.
Always enable RLS on every table from day one. Adding it later is painful.
Realtime: when to use it, when not to
Supabase has a Postgres Realtime feature that streams database changes via WebSockets. For collaborative features, live dashboards, or notifications — it's excellent:
// Subscribe to new messages in a channel
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
}, (payload) => {
messages.value.push(payload.new)
})
.subscribe()
// Clean up on unmount
onUnmounted(() => supabase.removeChannel(channel))
But don't default to realtime for everything. Most product data doesn't need to update live — standard fetch-on-load is simpler and cheaper. Use realtime specifically for collaborative features, live activity feeds, or anything where users need to see changes from other users without refreshing.
When to use Firebase instead
Supabase is better for most products — relational data models, standard SQL queries, better pricing at scale. But Firebase has genuine advantages in some cases:
- Very complex realtime requirements: Firebase Realtime Database is still better than Supabase Realtime for deeply hierarchical, highly-concurrent realtime use cases (like a live cursor-sharing feature in a collaborative document editor)
- React Native: Firebase has better mobile SDK support
- If you're already heavily invested in the Google ecosystem: Cloud Functions, BigQuery, Firebase Analytics all integrate cleanly
For the majority of startup MVPs — products with user accounts, structured data, maybe some realtime — Supabase wins on developer experience, data model flexibility, and cost.
Deploying the stack
Vercel is the default for Nuxt deployments. Zero config, fast, good edge network, free tier that's sufficient for MVPs. The Nuxt Vercel preset handles all the edge function / serverless routing automatically.
For products that need a more controlled environment (custom Docker setup, background workers, websocket servers), Fly.io is an excellent alternative. You get a real VM with reasonable pricing and simple deployment from a fly.toml.
This stack has shipped dozens of products and internal tools. It's not the only right answer, but it's a reliable one. If you're starting a new product and want to move fast without making bad architectural bets, it's where I'd start.