Supabase là open-source alternative của Firebase, cung cấp PostgreSQL database, authentication, realtime subscriptions, storage, và edge functions trong một platform duy nhất. Kết hợp với Next.js App Router, bạn có thể build một fullstack application production-ready trong vài giờ thay vì vài tuần. Bài viết này hướng dẫn cài đặt Supabase cho Next.js từ A đến Z: từ tạo project trên Supabase dashboard, cấu hình authentication với Server Components, thiết lập database với Row Level Security, cho đến deploy lên Vercel. Toàn bộ code trong bài chạy được ngay với Next.js 14+ App Router và Supabase JS v2.

Supabase Là Gì và Tại Sao Chọn Supabase Cho Next.js?

Supabase cung cấp đầy đủ backend-as-a-service stack mà developer cần:

  • PostgreSQL Database: Full SQL support, không phải NoSQL document store như Firebase Firestore.
  • Authentication: Email/password, magic link, OAuth (Google, GitHub, Discord...) và phone OTP.
  • Row Level Security (RLS): Policy-based access control trực tiếp ở database level, không cần viết middleware phức tạp.
  • Realtime: Subscribe vào database changes qua WebSocket.
  • Storage: File upload với CDN và access control tích hợp.
  • Edge Functions: Serverless functions chạy trên Deno.

Với Next.js App Router, Supabase cung cấp @supabase/ssr package được thiết kế đặc biệt để handle cookie-based auth seamlessly giữa Server Components, Client Components, Server Actions, và Route Handlers.

Yêu Cầu Trước Khi Bắt Đầu

- Node.js 18.17 trở lên
- Next.js 14+ với App Router
- Tài khoản Supabase (miễn phí tại supabase.com)
- Kiến thức cơ bản về React và TypeScript

Bước 1: Tạo Project Trên Supabase

Truy cập supabase.com và đăng nhập hoặc tạo tài khoản mới. Sau đó:

  1. Click "New Project" trên dashboard.
  2. Chọn Organization (hoặc tạo mới).
  3. Điền tên project, ví dụ my-nextjs-app.
  4. Đặt Database Password mạnh và lưu lại cẩn thận.
  5. Chọn Region gần người dùng của bạn nhất. Với Việt Nam, chọn Southeast Asia (Singapore).
  6. Click "Create new project" và chờ 1-2 phút để Supabase provision database.

Sau khi project được tạo, vào Settings → API để lấy hai giá trị quan trọng:

  • Project URL: https://[project-ref].supabase.co
  • anon (public) key: Key dùng ở client-side, safe để expose.
  • service_role key: Key có quyền bypass RLS, chỉ dùng ở server-side, tuyệt đối không expose ra client.

Bước 2: Tạo Next.js Project và Cài Đặt Dependencies

# Tạo Next.js project mới
npx create-next-app@latest my-app --typescript --tailwind --app --src-dir

cd my-app

# Cài đặt Supabase packages
npm install @supabase/supabase-js @supabase/ssr

# Optional: cài đặt Supabase CLI để manage migrations
npm install -g supabase

Bước 3: Cấu Hình Environment Variables

Tạo file .env.local ở root của project:

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://[your-project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

# Chỉ dùng ở server-side, KHÔNG có prefix NEXT_PUBLIC_
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here

Thêm .env.local vào .gitignore nếu chưa có:

echo ".env.local" >> .gitignore

Bước 4: Tạo Supabase Client Utilities

Với Next.js App Router, bạn cần 3 loại Supabase client khác nhau tùy context sử dụng. Tạo folder src/utils/supabase/:

4a. Client Component Client

// src/utils/supabase/client.ts
// Dùng trong Client Components ('use client')

import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

4b. Server Component Client

// src/utils/supabase/server.ts
// Dùng trong Server Components, Server Actions, Route Handlers

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 {
            // setAll được gọi từ Server Component
            // Có thể bỏ qua nếu có middleware refresh session
          }
        },
      },
    }
  );
}

4c. Middleware Client

// src/utils/supabase/middleware.ts
// Dùng trong Next.js middleware để refresh session

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(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 }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // QUAN TRỌNG: Không viết code giữa createServerClient và
  // supabase.auth.getUser(). Lý do: refresh session cần
  // chạy trước bất kỳ logic nào khác để đảm bảo auth state.
  const {
    data: { user },
  } = await supabase.auth.getUser();

  // Redirect về login nếu chưa auth (tùy theo route)
  if (
    !user &&
    !request.nextUrl.pathname.startsWith("/login") &&
    !request.nextUrl.pathname.startsWith("/auth")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

Bước 5: Cấu Hình Middleware

// src/middleware.ts (ở root của src folder)

import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/middleware";

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    // Skip các static files và API routes không cần auth
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Bước 6: Thiết Lập Authentication

6a. Tạo Login Page

// src/app/login/page.tsx

import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";

export default async function LoginPage() {
  // Nếu đã login thì redirect về dashboard
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (user) redirect("/dashboard");

  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-4 p-8">
        <h1 className="text-2xl font-bold">Đăng nhập</h1>

        <form action="/auth/login" method="POST" className="space-y-4">
          <div>
            <label htmlFor="email">Email</label>
            <input
              id="email"
              name="email"
              type="email"
              required
              className="w-full rounded border p-2"
            />
          </div>
          <div>
            <label htmlFor="password">Mật khẩu</label>
            <input
              id="password"
              name="password"
              type="password"
              required
              className="w-full rounded border p-2"
            />
          </div>
          <button
            type="submit"
            className="w-full rounded bg-green-600 p-2 text-white"
          >
            Đăng nhập
          </button>
        </form>
      </div>
    </div>
  );
}

6b. Tạo Auth Route Handler

// src/app/auth/login/route.ts

import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) {
    return NextResponse.redirect(
      new URL("/login?error=Invalid credentials", request.url),
      { status: 302 }
    );
  }

  return NextResponse.redirect(
    new URL("/dashboard", request.url),
    { status: 302 }
  );
}
// src/app/auth/signup/route.ts

import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const supabase = await createClient();

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      // URL người dùng được redirect về sau khi verify email
      emailRedirectTo: `${new URL(request.url).origin}/auth/callback`,
    },
  });

  if (error) {
    return NextResponse.redirect(
      new URL("/login?error=" + error.message, request.url),
      { status: 302 }
    );
  }

  return NextResponse.redirect(
    new URL("/login?message=Check your email", request.url),
    { status: 302 }
  );
}
// src/app/auth/callback/route.ts
// Xử lý email confirmation và OAuth callback

import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = searchParams.get("next") ?? "/dashboard";

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}
// src/app/auth/logout/route.ts

import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const supabase = await createClient();
  await supabase.auth.signOut();

  return NextResponse.redirect(
    new URL("/login", request.url),
    { status: 302 }
  );
}

Bước 7: Tạo Database Schema và Row Level Security

Vào Supabase Dashboard → SQL Editor và chạy script sau để tạo bảng ví dụ với RLS:

-- Tạo bảng todos
CREATE TABLE todos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

-- Policy: User chỉ thấy todos của chính mình
CREATE POLICY "Users can view their own todos"
  ON todos
  FOR SELECT
  USING (auth.uid() = user_id);

-- Policy: User chỉ insert todos cho chính mình
CREATE POLICY "Users can insert their own todos"
  ON todos
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Policy: User chỉ update todos của chính mình
CREATE POLICY "Users can update their own todos"
  ON todos
  FOR UPDATE
  USING (auth.uid() = user_id);

-- Policy: User chỉ delete todos của chính mình
CREATE POLICY "Users can delete their own todos"
  ON todos
  FOR DELETE
  USING (auth.uid() = user_id);

Generate TypeScript Types Từ Database

# Login Supabase CLI
supabase login

# Link với project của bạn
supabase link --project-ref [your-project-ref]

# Generate types
supabase gen types typescript --linked > src/types/database.types.ts

Sau khi generate, file database.types.ts sẽ chứa đầy đủ TypeScript types cho tất cả tables, views, và functions trong database của bạn.

Bước 8: Sử Dụng Supabase Trong Components

8a. Server Component: Fetch Data

// src/app/dashboard/page.tsx
// Server Component — không cần 'use client'

import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const supabase = await createClient();

  // Lấy thông tin user hiện tại
  const {
    data: { user },
  } = await supabase.auth.getUser();

  // Redirect về login nếu chưa auth
  if (!user) redirect("/login");

  // Fetch todos của user (RLS tự động filter theo user_id)
  const { data: todos, error } = await supabase
    .from("todos")
    .select("*")
    .order("created_at", { ascending: false });

  if (error) {
    console.error("Error fetching todos:", error);
  }

  return (
    <div className="container mx-auto p-8">
      <h1 className="text-2xl font-bold">
        Xin chào, {user.email}
      </h1>

      <div className="mt-6 space-y-2">
        {todos?.map((todo) => (
          <div key={todo.id} className="flex items-center gap-3 rounded border p-3">
            <span className={todo.completed ? "line-through text-gray-400" : ""}>
              {todo.title}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

8b. Server Action: Tạo Todo Mới

// src/app/dashboard/actions.ts
"use server";

import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";

export async function createTodo(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 { error } = await supabase
    .from("todos")
    .insert({
      title,
      user_id: user.id,
    });

  if (error) throw new Error(error.message);

  // Revalidate để Server Component re-fetch data mới
  revalidatePath("/dashboard");
}

export async function toggleTodo(id: string, completed: boolean) {
  const supabase = await createClient();

  const { error } = await supabase
    .from("todos")
    .update({ completed: !completed })
    .eq("id", id);

  if (error) throw new Error(error.message);
  revalidatePath("/dashboard");
}

export async function deleteTodo(id: string) {
  const supabase = await createClient();

  const { error } = await supabase
    .from("todos")
    .delete()
    .eq("id", id);

  if (error) throw new Error(error.message);
  revalidatePath("/dashboard");
}

8c. Client Component: Realtime Subscription

// src/components/RealtimeTodos.tsx
"use client";

import { createClient } from "@/utils/supabase/client";
import { useEffect, useState } from "react";

type Todo = {
  id: string;
  title: string;
  completed: boolean;
  created_at: string;
};

export default function RealtimeTodos({
  initialTodos,
}: {
  initialTodos: Todo[];
}) {
  const [todos, setTodos] = useState(initialTodos);
  const supabase = createClient();

  useEffect(() => {
    // Subscribe vào changes của bảng todos
    const channel = supabase
      .channel("todos-channel")
      .on(
        "postgres_changes",
        {
          event: "*", // INSERT, UPDATE, DELETE
          schema: "public",
          table: "todos",
        },
        (payload) => {
          if (payload.eventType === "INSERT") {
            setTodos((prev) => [payload.new as Todo, ...prev]);
          }
          if (payload.eventType === "UPDATE") {
            setTodos((prev) =>
              prev.map((todo) =>
                todo.id === payload.new.id
                  ? (payload.new as Todo)
                  : todo
              )
            );
          }
          if (payload.eventType === "DELETE") {
            setTodos((prev) =>
              prev.filter((todo) => todo.id !== payload.old.id)
            );
          }
        }
      )
      .subscribe();

    // Cleanup khi component unmount
    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  return (
    <ul className="space-y-2">
      {todos.map((todo) => (
        <li key={todo.id} className="flex items-center gap-3 rounded border p-3">
          <span className={todo.completed ? "line-through opacity-50" : ""}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

Bước 9: Cấu Hình OAuth (Google Login)

Bước 9a: Enable Google Provider trong Supabase

  1. Vào Supabase Dashboard → Authentication → Providers.
  2. Tìm Google và click Enable.
  3. Bạn cần Client IDClient Secret từ Google Cloud Console.
  4. Tạo OAuth credentials tại console.cloud.google.com → APIs & Services → Credentials.
  5. Authorized redirect URI: https://[project-ref].supabase.co/auth/v1/callback

Bước 9b: Thêm Google Login Button

// src/components/GoogleLoginButton.tsx
"use client";

import { createClient } from "@/utils/supabase/client";

export default function GoogleLoginButton() {
  const supabase = createClient();

  const handleGoogleLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: "google",
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    });
  };

  return (
    <button
      onClick={handleGoogleLogin}
      className="flex w-full items-center justify-center gap-3 rounded border p-3 hover:bg-gray-50"
    >
      <svg className="h-5 w-5" viewBox="0 0 24 24">
        {/* Google SVG icon */}
      </svg>
      Đăng nhập với Google
    </button>
  );
}

Bước 10: Deploy Lên Vercel

Cấu Hình Environment Variables Trên Vercel

Trong Vercel dashboard, vào Project → Settings → Environment Variables và thêm:

NEXT_PUBLIC_SUPABASE_URL        = https://[project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY   = your-anon-key
SUPABASE_SERVICE_ROLE_KEY       = your-service-role-key

Cập Nhật Allowed URLs Trong Supabase

Vào Supabase Dashboard → Authentication → URL Configuration:

Site URL:
https://your-app.vercel.app

Redirect URLs (thêm từng dòng):
https://your-app.vercel.app/**
http://localhost:3000/**

Deploy

# Push code lên GitHub
git add .
git commit -m "feat: add Supabase integration"
git push origin main

# Vercel sẽ tự động detect và deploy
# Hoặc deploy thủ công
npx vercel --prod

Cấu Trúc File Hoàn Chỉnh

src/
├── app/
│   ├── auth/
│   │   ├── callback/route.ts
│   │   ├── login/route.ts
│   │   ├── logout/route.ts
│   │   └── signup/route.ts
│   ├── dashboard/
│   │   ├── actions.ts
│   │   └── page.tsx
│   ├── login/
│   │   └── page.tsx
│   └── layout.tsx
├── components/
│   ├── GoogleLoginButton.tsx
│   └── RealtimeTodos.tsx
├── types/
│   └── database.types.ts
├── utils/
│   └── supabase/
│       ├── client.ts
│       ├── middleware.ts
│       └── server.ts
└── middleware.ts

Những Lỗi Phổ Biến Khi Cài Đặt Supabase

Lỗi "Auth session missing" sau khi login: Thường do middleware chưa được cấu hình đúng hoặc cookie domain không match. Kiểm tra lại file src/middleware.ts và đảm bảo updateSession được gọi đúng cách.

Lỗi "new row violates row-level security policy": RLS đang block insert vì user_id không match auth.uid(). Đảm bảo bạn đang set user_id từ authenticated user, không phải hardcode.

Realtime không hoạt động: Cần enable Realtime cho table trong Supabase Dashboard → Database → Replication và chọn table muốn subscribe.

TypeScript error với Supabase types: Chạy lại supabase gen types sau mỗi khi thay đổi schema database.

Environment variables không được nhận: Đảm bảo biến public (NEXT_PUBLIC_) được dùng ở client-side và biến không có prefix chỉ dùng ở server-side. Restart dev server sau khi thêm biến mới vào .env.local.

Câu Hỏi Thường Gặp Về Supabase Và Next.js

Supabase free plan có đủ dùng cho production không?

Free plan của Supabase bao gồm 500MB database, 1GB storage, 2GB bandwidth, và 50.000 MAU (Monthly Active Users). Đủ cho hầu hết side project và MVP. Giới hạn quan trọng nhất cần lưu ý là project trên free plan sẽ bị pause sau 7 ngày không có activity. Pro plan ($25/tháng) bỏ giới hạn này và tăng đáng kể mọi giới hạn khác.

Nên dùng Supabase Auth hay NextAuth.js với Supabase Database?

Nếu đã dùng Supabase, nên dùng Supabase Auth vì nó tích hợp sẵn với RLS thông qua auth.uid(). Dùng NextAuth.js với Supabase Database cần thêm bước sync user ID giữa hai hệ thống, phức tạp hơn không cần thiết. Chỉ nên dùng NextAuth.js nếu bạn cần các provider hoặc strategy mà Supabase Auth chưa hỗ trợ, hoặc nếu bạn đang dùng Supabase chỉ như một database không phải platform.

Row Level Security có ảnh hưởng đến performance không?

Có nhưng overhead rất nhỏ trong hầu hết trường hợp. RLS policies được evaluate ở database level, không phải application level, nên rất hiệu quả. Để optimize, đảm bảo các cột được dùng trong RLS policies (như user_id) có index. Với query phức tạp, có thể dùng EXPLAIN ANALYZE trong SQL Editor để kiểm tra query plan và xem RLS có đang gây bottleneck không.

Làm thế nào để test Supabase locally mà không cần kết nối internet?

Supabase CLI cung cấp local development stack đầy đủ chạy trên Docker. Chạy supabase start để khởi động local instance với PostgreSQL, Auth, Storage, và Studio UI tại localhost:54323. Dùng supabase db push để apply migrations và supabase db pull để sync schema từ cloud. Local URL sẽ là http://localhost:54321 với anon key được in ra terminal sau khi start.

Supabase có hỗ trợ database migrations không?

Có. Supabase CLI quản lý migrations qua folder supabase/migrations/. Tạo migration mới với supabase migration new [tên], viết SQL vào file được tạo, sau đó apply với supabase db push (lên cloud) hoặc supabase db reset (local). Migrations được track theo timestamp và chạy theo thứ tự, tương tự Flyway hay Liquibase nhưng đơn giản hơn nhiều.

Kết Luận: Supabase + Next.js Là Stack Fullstack Tối Ưu Nhất 2026

Với hướng dẫn này, bạn đã có một Next.js application với Supabase backend hoàn chỉnh: authentication cookie-based hoạt động đúng với App Router, database với Row Level Security bảo vệ data, realtime subscription, và sẵn sàng deploy lên Vercel. Đây là stack cho phép một developer solo build và ship production application nhanh nhất có thể trong năm 2026.

Bước tiếp theo sau khi setup xong: viết test cho RLS policies bằng supabase/tests/, thiết lập database backup tự động trong Supabase dashboard, và consider upgrade lên Pro plan khi MAU vượt 50.000 hoặc khi cần project không bị pause.