Untuk membuat endpoint API di Next.js yang dapat membaca user terautentikasi dan mengakses database Supabase dengan aman, gunakan Supabase server client di dalam Route Handler. Client ini berjalan di server, membaca sesi dari cookie, lalu menggunakan identitas user untuk menjalankan query database.

Pola ini berbeda dari akses Supabase di browser. Kode browser cocok untuk operasi yang memang dilakukan oleh pengguna secara langsung, sedangkan server client berguna ketika endpoint perlu memvalidasi autentikasi, memeriksa input, mengatur status code, dan membatasi data berdasarkan user yang sedang login.

Prasyarat dan struktur endpoint

Contoh berikut menggunakan App Router Next.js dengan endpoint:

app/api/notes/route.ts

Endpoint tersebut akan menyediakan:

  • GET /api/notes untuk mengambil catatan milik user yang sedang login.
  • POST /api/notes untuk membuat catatan baru atas nama user tersebut.

Misalkan tabel Supabase bernama notes memiliki kolom berikut:

  • id
  • user_id
  • title
  • content
  • created_at

Pastikan dependensi Supabase SSR sudah tersedia pada project:

npm install @supabase/supabase-js @supabase/ssr

Simpan URL project dan anon key di environment variable. Nama variabel berikut umum digunakan oleh aplikasi Next.js:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

Anon key boleh digunakan oleh client karena keamanan utama tetap ditegakkan oleh autentikasi dan Row Level Security. Sebaliknya, service role key tidak boleh diekspos ke browser dan tidak seharusnya dipakai untuk endpoint biasa yang bekerja atas nama user.

Membuat Supabase Server Client

Buat helper server client, misalnya di lib/supabase/server.ts. Helper ini membaca cookie sesi dan meneruskan perubahan cookie yang diperlukan oleh Supabase.

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createSupabaseServerClient() {
  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 {
            // Pada konteks tertentu, cookie tidak dapat ditulis.
            // Refresh sesi sebaiknya juga ditangani oleh middleware.
          }
        },
      },
    },
  );
}

Implementasi cookies() dapat berbeda bergantung pada versi Next.js yang digunakan. Jika versi project Anda menggunakan API cookie sinkron, sesuaikan pemanggilan tersebut dengan dokumentasi versi yang sedang dipakai.

Dalam aplikasi dengan autentikasi berbasis cookie, middleware biasanya digunakan untuk membantu memperbarui token sesi yang hampir kedaluwarsa. Route Handler tetap perlu memanggil getUser() untuk memastikan request memiliki user yang valid.

Perbedaan client server dan client browser

Supabase client di browser

Browser client berjalan di sisi pengguna. Client ini berguna untuk fitur seperti login, logout, atau operasi interaktif yang memang dilakukan dari browser. Namun, kode browser tidak boleh dipercaya untuk menentukan apakah sebuah user boleh mengakses data tertentu.

Supabase server client

Server client berjalan di Route Handler, Server Component, atau server-side code lainnya. Client ini dapat membaca sesi dari cookie request sehingga endpoint dapat memeriksa identitas user sebelum mengakses data.

Server client bukan berarti otomatis memiliki hak istimewa penuh. Jika dibuat menggunakan anon key, query tetap berjalan dengan konteks user dan dibatasi oleh Row Level Security. Ini berbeda dari service role client yang dapat melewati RLS dan harus diperlakukan sebagai kredensial sangat sensitif.

Membuat helper untuk mengambil user terautentikasi

Agar pemeriksaan autentikasi konsisten, gunakan helper kecil berikut:

import { createSupabaseServerClient } from '@/lib/supabase/server';

export async function requireUser() {
  const supabase = await createSupabaseServerClient();
  const {
    data: { user },
    error,
  } = await supabase.auth.getUser();

  if (error || !user) {
    return { supabase, user: null };
  }

  return { supabase, user };
}

getUser() digunakan untuk validasi autentikasi di server. Jangan hanya membaca data user dari input body, query string, atau nilai yang dikirim browser karena semua nilai tersebut dapat dimanipulasi.

Membuat endpoint GET dan POST

Berikut implementasi lengkap app/api/notes/route.ts. Contoh ini memvalidasi autentikasi, memeriksa input JSON, membatasi panjang field, dan mengembalikan status HTTP yang sesuai.

import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/auth/require-user';

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export async function GET() {
  const { supabase, user } = await requireUser();

  if (!user) {
    return NextResponse.json(
      { error: 'Autentikasi diperlukan' },
      { status: 401 },
    );
  }

  const { data, error } = await supabase
    .from('notes')
    .select('id, title, content, created_at')
    .eq('user_id', user.id)
    .order('created_at', { ascending: false });

  if (error) {
    console.error('GET /api/notes failed:', error);

    return NextResponse.json(
      { error: 'Gagal mengambil catatan' },
      { status: 500 },
    );
  }

  return NextResponse.json({ data }, { status: 200 });
}

export async function POST(request: Request) {
  const { supabase, user } = await requireUser();

  if (!user) {
    return NextResponse.json(
      { error: 'Autentikasi diperlukan' },
      { status: 401 },
    );
  }

  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: 'Body harus berupa JSON yang valid' },
      { status: 400 },
    );
  }

  if (!isRecord(body)) {
    return NextResponse.json(
      { error: 'Format body tidak valid' },
      { status: 400 },
    );
  }

  const title = typeof body.title === 'string' ? body.title.trim() : '';
  const content = typeof body.content === 'string' ? body.content.trim() : '';

  if (!title || title.length > 120) {
    return NextResponse.json(
      { error: 'title wajib diisi dan maksimal 120 karakter' },
      { status: 400 },
    );
  }

  if (content.length > 10000) {
    return NextResponse.json(
      { error: 'content maksimal 10000 karakter' },
      { status: 400 },
    );
  }

  const { data, error } = await supabase
    .from('notes')
    .insert({
      user_id: user.id,
      title,
      content,
    })
    .select('id, title, content, created_at')
    .single();

  if (error) {
    console.error('POST /api/notes failed:', error);

    return NextResponse.json(
      { error: 'Gagal membuat catatan' },
      { status: 500 },
    );
  }

  return NextResponse.json({ data }, { status: 201 });
}

Pada contoh tersebut, user_id tidak diambil dari request. Nilainya selalu berasal dari user.id yang diperoleh dari sesi terautentikasi. Ini penting untuk mencegah serangan horizontal privilege escalation, yaitu ketika user mencoba membuat atau membaca data milik user lain dengan mengganti ID di request.

Menegakkan keamanan dengan Row Level Security

Pemeriksaan di Route Handler harus dilengkapi dengan RLS di PostgreSQL. RLS menjadi lapisan pertahanan kedua apabila ada bug pada kode aplikasi atau query yang terlalu longgar.

Contoh kebijakan untuk tabel notes:

alter table public.notes enable row level security;

create policy "Users can read their own notes"
on public.notes
for select
to authenticated
using (auth.uid() = user_id);

create policy "Users can create their own notes"
on public.notes
for insert
to authenticated
with check (auth.uid() = user_id);

Dengan kebijakan tersebut, user hanya dapat membaca baris yang user_id-nya sama dengan identitas sesi. Saat insert, database juga memastikan nilai user_id yang dimasukkan sesuai dengan auth.uid().

Catatan: Filter .eq('user_id', user.id) pada query tetap baik untuk kejelasan dan efisiensi query, tetapi jangan menganggap filter aplikasi sebagai satu-satunya mekanisme keamanan. RLS harus tetap diaktifkan.

Status code dan strategi penanganan error

Gunakan status code yang membedakan jenis kegagalan agar client dapat merespons dengan benar:

  • 200 OK untuk GET yang berhasil.
  • 201 Created untuk resource baru yang berhasil dibuat.
  • 400 Bad Request untuk JSON tidak valid atau field tidak memenuhi aturan.
  • 401 Unauthorized jika tidak ada sesi user yang valid.
  • 403 Forbidden jika user terautentikasi tetapi tidak memiliki izin terhadap resource tertentu.
  • 404 Not Found jika resource yang diminta tidak ada atau sengaja disembunyikan demi keamanan.
  • 500 Internal Server Error untuk kegagalan internal atau database yang tidak terduga.

Jangan mengirim detail error database secara langsung ke browser pada production. Detail tersebut dapat membocorkan nama tabel, struktur kolom, atau informasi internal. Simpan detail teknis di log server dan kirim pesan generik ke client.

Untuk aplikasi yang lebih besar, error dapat dipetakan berdasarkan kode error Supabase atau error domain internal. Namun, pemetaan tersebut harus dilakukan secara hati-hati agar error validasi tidak keliru dikembalikan sebagai 500 dan agar informasi sensitif tidak bocor.

Kesalahan umum yang perlu dihindari

  • Menggunakan service role key di browser. Key tersebut dapat melewati RLS dan jika bocor, seluruh database berpotensi disalahgunakan.
  • Mempercayai user ID dari body. Ambil identitas user dari sesi server, bukan dari input client.
  • Tidak mengaktifkan RLS. Endpoint yang tampak aman masih dapat mengekspos data jika ada query lain yang lupa memberikan filter.
  • Menggunakan getSession() sebagai satu-satunya validasi server. Untuk keputusan otorisasi di server, validasi user melalui mekanisme autentikasi server yang sesuai, seperti getUser().
  • Tidak memvalidasi ukuran dan tipe input. JSON yang valid belum tentu memiliki struktur atau nilai yang valid.
  • Mengembalikan semua kolom database. Pilih kolom menggunakan select() agar field internal atau sensitif tidak ikut terkirim.

Pengujian dan debugging endpoint

Uji endpoint dengan sesi user yang valid dan tanpa sesi. Contoh request POST:

curl -X POST http://localhost:3000/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Catatan pertama","content":"Isi catatan"}'

Jika endpoint memakai autentikasi berbasis cookie, request dari curl harus menyertakan cookie sesi yang valid. Pengujian dari browser atau integration test yang sudah melakukan login biasanya lebih praktis untuk memverifikasi alur autentikasi.

Jika hasilnya selalu 401, periksa apakah cookie sesi diteruskan, apakah konfigurasi middleware sudah benar, dan apakah environment variable tersedia di server. Jika hasilnya 500, lihat log server tanpa mengekspos secret. Jika query mengembalikan data kosong, periksa nilai user_id, status user, dan policy RLS di Supabase.

Ringkasan implementasi

Route Handler Next.js dengan Supabase Server Client sebaiknya mengikuti urutan berikut:

  1. Buat server client yang membaca sesi dari cookie.
  2. Validasi user dengan supabase.auth.getUser().
  3. Kembalikan 401 sebelum mengakses data jika user anonim.
  4. Validasi dan normalisasi body request sebelum query database.
  5. Gunakan ID user dari sesi, bukan dari input client.
  6. Batasi kolom yang dipilih dan aktifkan RLS di database.
  7. Gunakan status code yang tepat dan jangan bocorkan detail error internal.

Dengan pola ini, endpoint GET dan POST tetap sederhana, tetapi memiliki pemisahan tanggung jawab yang jelas antara autentikasi server, validasi request, query database, dan kontrol akses pada tingkat database.