Build a Full-Stack App with Next.js and Supabase in 20 Minutes
Build a complete full-stack application with Next.js 15 and Supabase from scratch. Authentication, database, CRUD operations, and deployment — all in 20 minutes.
Build a Full-Stack App with Next.js and Supabase in 20 Minutes#
You don't need a backend team. You don't need to configure servers. You don't even need to write API routes.
With Next.js and Supabase, you can go from zero to a deployed full-stack app — with authentication, a database, and CRUD operations — in 20 minutes.
This is not theory. We are building a real app, step by step. By the end, you will have a working task manager with user accounts, protected routes, and a live deployment.
Let's go.
What We Are Building#
A full-stack task manager with:
- User authentication (sign up, log in, log out)
- PostgreSQL database with Row Level Security
- CRUD operations (create, read, update, delete tasks)
- Protected routes (only logged-in users see the dashboard)
- Deployment to Vercel (free)
Tech stack:
├── Next.js 15 (App Router)
├── Supabase (Database + Auth)
├── TypeScript
├── Tailwind CSS
└── Vercel (hosting)
Minute 0-3: Project Setup#
Create the Next.js App#
npx create-next-app@latest task-manager --typescript --tailwind --app --src-dir
cd task-manager
Install Supabase#
npm install @supabase/supabase-js @supabase/ssr
Create a Supabase Project#
- Go to supabase.com → New Project
- Pick a name, set a database password, choose a region
- Copy your Project URL and anon key from Settings → API
Add Environment Variables#
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Minute 3-5: Supabase Client Setup#
Create two Supabase clients — one for the server, one for the browser.
Server Client#
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — can't set cookies
}
},
},
}
)
}
Browser Client#
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Middleware (Session Refresh)#
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users to login
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: ['/dashboard/:path*'],
}
Minute 5-8: Database Setup#
Go to your Supabase dashboard → SQL Editor and run:
-- Create tasks table
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Users can only see their own tasks
CREATE POLICY "Users can view own tasks"
ON tasks FOR SELECT
USING (auth.uid() = user_id);
-- Users can create their own tasks
CREATE POLICY "Users can create tasks"
ON tasks FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own tasks
CREATE POLICY "Users can update own tasks"
ON tasks FOR UPDATE
USING (auth.uid() = user_id);
-- Users can delete their own tasks
CREATE POLICY "Users can delete own tasks"
ON tasks FOR DELETE
USING (auth.uid() = user_id);
Row Level Security means every query is automatically filtered to only return the current user's data. You never need to write WHERE user_id = currentUser in your app code — the database handles it.
Minute 8-12: Authentication Pages#
Sign Up Page#
// src/app/signup/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import Link from 'next/link'
export default function SignUpPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleSignUp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="min-h-screen flex items-center justify-center">
<form onSubmit={handleSignUp} className="w-full max-w-md space-y-4 p-8">
<h1 className="text-2xl font-bold">Create Account</h1>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border rounded-lg"
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border rounded-lg"
minLength={6}
required
/>
<button
type="submit"
disabled={loading}
className="w-full p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
<p className="text-center text-sm">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Log in
</Link>
</p>
</form>
</div>
)
}
Login Page#
// src/app/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import Link from 'next/link'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="min-h-screen flex items-center justify-center">
<form onSubmit={handleLogin} className="w-full max-w-md space-y-4 p-8">
<h1 className="text-2xl font-bold">Log In</h1>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border rounded-lg"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border rounded-lg"
required
/>
<button
type="submit"
disabled={loading}
className="w-full p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Log In'}
</button>
<p className="text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-blue-600 hover:underline">
Sign up
</Link>
</p>
</form>
</div>
)
}
Minute 12-18: The Dashboard (CRUD)#
Server Actions#
// src/app/dashboard/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function addTask(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const title = formData.get('title') as string
const description = formData.get('description') as string
await supabase.from('tasks').insert({
title,
description,
user_id: user.id,
})
revalidatePath('/dashboard')
}
export async function toggleTask(taskId: string, completed: boolean) {
const supabase = await createClient()
await supabase
.from('tasks')
.update({ completed: !completed })
.eq('id', taskId)
revalidatePath('/dashboard')
}
export async function deleteTask(taskId: string) {
const supabase = await createClient()
await supabase
.from('tasks')
.delete()
.eq('id', taskId)
revalidatePath('/dashboard')
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
}
Dashboard Page#
// src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { addTask, toggleTask, deleteTask, signOut } from './actions'
export default async function Dashboard() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false })
return (
<div className="max-w-2xl mx-auto p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">My Tasks</h1>
<form action={signOut}>
<button
type="submit"
className="text-sm text-gray-500 hover:text-gray-700"
>
Sign Out
</button>
</form>
</div>
{/* Add Task Form */}
<form action={addTask} className="mb-8 space-y-3">
<input
name="title"
placeholder="Task title"
className="w-full p-3 border rounded-lg"
required
/>
<input
name="description"
placeholder="Description (optional)"
className="w-full p-3 border rounded-lg"
/>
<button
type="submit"
className="w-full p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add Task
</button>
</form>
{/* Task List */}
<div className="space-y-3">
{tasks?.length === 0 && (
<p className="text-gray-500 text-center py-8">
No tasks yet. Add one above!
</p>
)}
{tasks?.map((task) => (
<div
key={task.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
<form action={toggleTask.bind(null, task.id, task.completed)}>
<button type="submit" className="text-xl">
{task.completed ? '✅' : '⬜'}
</button>
</form>
<div>
<p className={task.completed ? 'line-through text-gray-400' : ''}>
{task.title}
</p>
{task.description && (
<p className="text-sm text-gray-500">{task.description}</p>
)}
</div>
</div>
<form action={deleteTask.bind(null, task.id)}>
<button
type="submit"
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</form>
</div>
))}
</div>
</div>
)
}
Notice there are no API routes, no useEffect, no loading states for data fetching. Server Components fetch data on the server. Server Actions handle mutations. The code is simple and secure.
Minute 18-20: Deploy to Vercel#
Push to GitHub#
git add .
git commit -m "Task manager with Next.js + Supabase"
git remote add origin https://github.com/your-username/task-manager.git
git push -u origin main
Deploy on Vercel#
- Go to vercel.com → Import Project
- Select your GitHub repository
- Add environment variables:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
- Click Deploy
Your app is live. That's it.
Configure Supabase Auth Redirect#
In your Supabase dashboard → Authentication → URL Configuration:
- Set Site URL to
https://your-app.vercel.app - Add
https://your-app.vercel.app/auth/callbackto Redirect URLs
What You Just Built#
In 20 minutes, you built a production-ready full-stack app with:
- Authentication — sign up, log in, log out with secure session management
- Database — PostgreSQL with Row Level Security protecting every query
- CRUD — create, read, update, delete tasks with Server Actions
- Protected routes — middleware redirects unauthenticated users
- Deployment — live on Vercel with HTTPS and CDN
No Express server. No API routes. No backend code. Just Next.js + Supabase.
Next Steps#
Now that you have the foundation, here's what to add:
- Real-time updates — use Supabase Realtime to sync tasks across tabs
- File uploads — attach images to tasks with Supabase Storage
- Teams — add organizations and shared task lists
- Stripe payments — monetize with subscriptions
Want to go deeper? Read our Complete Guide to Building SaaS with Next.js and Supabase for multi-tenancy, Stripe integration, RBAC, and production deployment patterns.
Related:
Frequently Asked Questions
Continue Reading
Fix Supabase Auth Session Not Persisting After Refresh
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Handle Supabase Auth Errors in Next.js Middleware
Auth errors crashing your Next.js middleware? Learn how to handle Supabase auth errors gracefully with proper error handling patterns.
Supabase Auth Redirect Not Working Next.js App Router
Auth redirects failing in Next.js App Router? Learn the exact cause and fix it with this complete guide including OAuth and magic link redirects.
Browse by Topic
Find stories that matter to you.