Full-Stack App with Supabase & Next.js
Build authentication, database, and real-time features from scratch. Complete walkthrough with code.
Why Supabase + Next.js is the Best Stack Right Now
I used to spend days setting up authentication, databases, and real-time features. Now? Supabase + Next.js handles all of it.
In this post, I'll build a complete real-time collaborative note-taking app from scratch. By the end, you'll have:
- User authentication (Google + email/password)
- PostgreSQL database
- Real-time updates
- Row-level security
- Deployed on Vercel
Step 1: Setup
Create a Supabase Project
Go to supabase.com, sign up, and create a new project. This takes 2 minutes. You'll get a PostgreSQL database instantly.
Create a Next.js App
npx create-next-app@latest my-app cd my-app npm install @supabase/supabase-js
Add Env Variables
Copy your Supabase URL and API key to .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-api-key
Step 2: Database Schema
Create Tables
Open the Supabase dashboard and run this SQL:
-- Notes table CREATE TABLE notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) NOT NULL, title TEXT NOT NULL, content TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Share table CREATE TABLE note_shares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), note_id UUID REFERENCES notes(id) ON DELETE CASCADE, shared_with_email TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() );
Enable Row Level Security
-- Enable RLS on notes ALTER TABLE notes ENABLE ROW LEVEL SECURITY; -- Users can only see their own notes CREATE POLICY "Users can see own notes" ON notes FOR SELECT USING (auth.uid() = user_id); -- Users can only insert their own notes CREATE POLICY "Users can insert own notes" ON notes FOR INSERT WITH CHECK (auth.uid() = user_id);
Step 3: Authentication
Create Auth Helper
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export const signUp = (email: string, password: string) =>
supabase.auth.signUp({ email, password })
export const signIn = (email: string, password: string) =>
supabase.auth.signInWithPassword({ email, password })
export const signOut = () => supabase.auth.signOut()Create Login Component
'use client'
import { useState } from 'react'
import { signIn } from '@/lib/supabase'
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const { error } = await signIn(email, password)
if (error) alert(error.message)
setLoading(false)
}
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button disabled={loading}>
{loading ? 'Loading...' : 'Sign In'}
</button>
</form>
)
}Step 4: CRUD Operations
Fetch Notes (Read)
const getNotes = async () => {
const { data, error } = await supabase
.from('notes')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
}Create Note
const createNote = async (title: string, content: string) => {
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('notes')
.insert([
{
user_id: user?.id,
title,
content
}
])
.select()
if (error) throw error
return data
}Update Note
const updateNote = async (id: string, title: string, content: string) => {
const { data, error } = await supabase
.from('notes')
.update({ title, content, updated_at: new Date() })
.eq('id', id)
.select()
if (error) throw error
return data
}Delete Note
const deleteNote = async (id: string) => {
const { error } = await supabase
.from('notes')
.delete()
.eq('id', id)
if (error) throw error
}Step 5: Real-Time Updates
Subscribe to Changes
'use client'
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
export function NotesList() {
const [notes, setNotes] = useState([])
useEffect(() => {
// Get initial data
getNotes()
// Subscribe to changes
const subscription = supabase
.from('notes')
.on('*', (payload) => {
if (payload.eventType === 'INSERT') {
setNotes(prev => [payload.new, ...prev])
} else if (payload.eventType === 'UPDATE') {
setNotes(prev =>
prev.map(n => n.id === payload.new.id ? payload.new : n)
)
} else if (payload.eventType === 'DELETE') {
setNotes(prev => prev.filter(n => n.id !== payload.old.id))
}
})
.subscribe()
return () => subscription.unsubscribe()
}, [])
return (
<div>
{notes.map(note => (
<div key={note.id}>{note.title}</div>
))}
</div>
)
}Step 6: Deploy on Vercel
Connect Repository
Push your code to GitHub, then connect to Vercel. Takes 2 minutes.
Add Environment Variables
In Vercel dashboard → Settings → Environment Variables, add:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
Deploy
Done. Your app is live.
Key Takeaways
- Supabase = Firebase but with PostgreSQL - Better for complex queries
- Row-level security is built-in - Your users can't access others' data
- Real-time is easy - Just subscribe to changes
- Cost is minimal - Free tier covers most side projects
- Deploy in minutes - Supabase + Next.js + Vercel is the fastest stack
Next Steps
From here, you can add:
- File uploads to Supabase Storage
- Webhooks for notifications
- Edge Functions for complex logic
- Full-text search
The fundamentals you've learned cover 80% of full-stack needs.
Key Takeaways
- •Practical tools and techniques you can implement today
- •Real-world examples from production systems
- •Common mistakes to avoid and how to fix them
Related Guides
Want more articles like this?
Subscribe to get practical guides and case studies delivered to your inbox. No spam, just real systems that work.