DEV Community

Cover image for Build a Full-Stack App with Next.js and Supabase in 20 Minutes
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Build a Full-Stack App with Next.js and Supabase 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install Supabase

npm install @supabase/supabase-js @supabase/ssr
Enter fullscreen mode Exit fullscreen mode

Create a Supabase Project

  1. Go to supabase.comNew Project
  2. Pick a name, set a database password, choose a region
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
          }
        },
      },
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

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!
  )
}
Enter fullscreen mode Exit fullscreen mode

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*'],
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode


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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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&apos;t have an account?{' '}
          <Link href="/signup" className="text-blue-600 hover:underline">
            Sign up
          </Link>
        </p>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode

Deploy on Vercel

  1. Go to vercel.comImport Project
  2. Select your GitHub repository
  3. Add environment variables:
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY
  4. 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/callback to 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:

  1. Real-time updates — use Supabase Realtime to sync tasks across tabs
  2. File uploads — attach images to tasks with Supabase Storage
  3. Teams — add organizations and shared task lists
  4. 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:


Originally published at https://iloveblogs.blog

Top comments (0)