Case Study: CRUD Application (Admin Dashboard)¶
Tổng quan¶
Xây dựng admin dashboard với CRUD operations sử dụng Supabase REST API.
Yêu cầu¶
- User authentication (admin role)
- Product management (CRUD)
- Order management (view, update status)
- Dashboard analytics
- Role-based access control
Kết quả mong đợi¶
- Fully functional admin dashboard
- Protected routes
- Real-time data updates
- Responsive design
Kiến trúc¶
┌─────────────────────────────────────────────────────────────┐
│ ADMIN DASHBOARD ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Admin User │
│ │ │
│ ▼ │
│ Next.js App (Cloudflare Workers) │
│ │ │
│ ├──▶ Server Components (SSR data fetching) │
│ │ │ │
│ │ └──▶ Supabase REST API (PostgREST) │
│ │ ├── GET /rest/v1/products │
│ │ ├── POST /rest/v1/products │
│ │ ├── PATCH /rest/v1/products?id=eq.xxx │
│ │ └── DELETE /rest/v1/products?id=eq.xxx │
│ │ │
│ ├──▶ Client Components (mutations, realtime) │
│ │ │
│ └──▶ Supabase Auth (admin verification) │
│ │
│ Database (PostgreSQL + RLS) │
│ ├── products │
│ ├── orders │
│ ├── profiles (with role) │
│ └── RLS policies (admin-only access) │
│ │
└─────────────────────────────────────────────────────────────┘
Stack được sử dụng¶
| Component | Technology | Lý do |
|---|---|---|
| Frontend | Next.js (SSR/Client) | Admin dashboard needs interactivity |
| API | Supabase REST (PostgREST) | Auto-generated CRUD, no backend code |
| Auth | Supabase Auth | Session management |
| Authorization | RLS Policies | Database-level security |
| Hosting | Cloudflare Workers | Edge deployment |
Database Schema¶
Tables¶
-- supabase/migrations/001_admin_tables.sql
-- Profiles with role
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin', 'super_admin')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Products
CREATE TABLE public.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
category TEXT,
image_url TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Orders
CREATE TABLE public.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
total NUMERIC(10,2) NOT NULL,
shipping_address JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Order items
CREATE TABLE public.order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES public.orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES public.products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10,2) NOT NULL
);
-- Indexes
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_active ON products(is_active);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);
RLS Policies (Admin Only)¶
-- Enable RLS
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Helper function: Check if user is admin
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role IN ('admin', 'super_admin')
);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- Products: Admin only
CREATE POLICY "Admin can do all on products"
ON products FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
-- Public can view active products
CREATE POLICY "Public can view active products"
ON products FOR SELECT
USING (is_active = true);
-- Orders: Admin can manage all
CREATE POLICY "Admin can do all on orders"
ON orders FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
-- Users can view own orders
CREATE POLICY "Users view own orders"
ON orders FOR SELECT
USING (user_id = auth.uid());
-- Order items: Through order access
CREATE POLICY "Access order items through order"
ON order_items FOR SELECT
USING (
EXISTS (
SELECT 1 FROM orders
WHERE orders.id = order_items.order_id
AND (orders.user_id = auth.uid() OR is_admin())
)
);
-- Profiles
CREATE POLICY "Users view own profile"
ON profiles FOR SELECT
USING (id = auth.uid() OR is_admin());
CREATE POLICY "Admin can update any profile"
ON profiles FOR UPDATE
USING (is_admin())
WITH CHECK (is_admin());
Implementation¶
Project Structure¶
admin-dashboard/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # Dashboard home
│ ├── login/
│ │ └── page.tsx
│ ├── products/
│ │ ├── page.tsx # Product list
│ │ ├── new/
│ │ │ └── page.tsx # Create product
│ │ └── [id]/
│ │ ├── page.tsx # View product
│ │ └── edit/
│ │ └── page.tsx # Edit product
│ └── orders/
│ ├── page.tsx # Order list
│ └── [id]/
│ └── page.tsx # Order detail
├── components/
│ ├── layout/
│ │ ├── Sidebar.tsx
│ │ ├── Header.tsx
│ │ └── AdminGuard.tsx
│ ├── products/
│ │ ├── ProductTable.tsx
│ │ ├── ProductForm.tsx
│ │ └── ProductCard.tsx
│ └── orders/
│ ├── OrderTable.tsx
│ └── OrderStatusBadge.tsx
├── lib/
│ ├── supabase/
│ │ ├── server.ts
│ │ └── client.ts
│ └── types.ts
└── hooks/
├── use-products.ts
└── use-orders.ts
Admin Guard Component¶
// components/layout/AdminGuard.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export async function AdminGuard({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Check admin role
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !['admin', 'super_admin'].includes(profile.role)) {
redirect('/unauthorized');
}
return <>{children}</>;
}
Product List (Server Component)¶
// app/products/page.tsx
import { createClient } from '@/lib/supabase/server';
import { ProductTable } from '@/components/products/ProductTable';
import { AdminGuard } from '@/components/layout/AdminGuard';
import Link from 'next/link';
export default async function ProductsPage({
searchParams
}: {
searchParams: { page?: string; search?: string }
}) {
return (
<AdminGuard>
<ProductsContent searchParams={searchParams} />
</AdminGuard>
);
}
async function ProductsContent({
searchParams
}: {
searchParams: { page?: string; search?: string }
}) {
const supabase = await createClient();
const page = parseInt(searchParams.page || '1');
const pageSize = 10;
const offset = (page - 1) * pageSize;
// Build query using REST API patterns
let query = supabase
.from('products')
.select('*', { count: 'exact' });
// Search filter
if (searchParams.search) {
query = query.ilike('name', `%${searchParams.search}%`);
}
// Pagination
const { data: products, count, error } = await query
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
if (error) {
console.error('Error fetching products:', error);
return <div>Error loading products</div>;
}
const totalPages = Math.ceil((count || 0) / pageSize);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Products</h1>
<Link
href="/products/new"
className="bg-blue-600 text-white px-4 py-2 rounded-lg"
>
Add Product
</Link>
</div>
<ProductTable products={products || []} />
<div className="mt-4 flex justify-center gap-2">
{Array.from({ length: totalPages }, (_, i) => (
<Link
key={i + 1}
href={`/products?page=${i + 1}`}
className={`px-3 py-1 rounded ${
page === i + 1 ? 'bg-blue-600 text-white' : 'bg-gray-200'
}`}
>
{i + 1}
</Link>
))}
</div>
</div>
);
}
Product Form (Client Component)¶
// components/products/ProductForm.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
interface ProductFormProps {
product?: {
id: string;
name: string;
description: string;
price: number;
stock: number;
category: string;
is_active: boolean;
};
}
export function ProductForm({ product }: ProductFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const supabase = createClient();
const isEditing = !!product;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const data = {
name: formData.get('name') as string,
description: formData.get('description') as string,
price: parseFloat(formData.get('price') as string),
stock: parseInt(formData.get('stock') as string),
category: formData.get('category') as string,
is_active: formData.get('is_active') === 'on',
};
try {
if (isEditing) {
// UPDATE via REST API
const { error } = await supabase
.from('products')
.update(data)
.eq('id', product.id);
if (error) throw error;
} else {
// INSERT via REST API
const { error } = await supabase
.from('products')
.insert(data);
if (error) throw error;
}
router.push('/products');
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!product || !confirm('Are you sure?')) return;
setLoading(true);
try {
// DELETE via REST API
const { error } = await supabase
.from('products')
.delete()
.eq('id', product.id);
if (error) throw error;
router.push('/products');
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="max-w-lg space-y-4">
{error && (
<div className="bg-red-100 text-red-700 p-3 rounded">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
name="name"
defaultValue={product?.name}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
name="description"
defaultValue={product?.description}
rows={3}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Price</label>
<input
name="price"
type="number"
step="0.01"
defaultValue={product?.price}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock</label>
<input
name="stock"
type="number"
defaultValue={product?.stock || 0}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Category</label>
<select
name="category"
defaultValue={product?.category}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">Select category</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
name="is_active"
type="checkbox"
defaultChecked={product?.is_active ?? true}
className="w-4 h-4"
/>
<label className="text-sm">Active</label>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{loading ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</button>
{isEditing && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="bg-red-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
Delete
</button>
)}
</div>
</form>
);
}
Dashboard Analytics¶
// app/page.tsx (Dashboard)
import { createClient } from '@/lib/supabase/server';
import { AdminGuard } from '@/components/layout/AdminGuard';
export default async function DashboardPage() {
return (
<AdminGuard>
<DashboardContent />
</AdminGuard>
);
}
async function DashboardContent() {
const supabase = await createClient();
// Fetch stats in parallel
const [
{ count: productCount },
{ count: orderCount },
{ data: recentOrders },
{ data: orderStats }
] = await Promise.all([
supabase.from('products').select('*', { count: 'exact', head: true }),
supabase.from('orders').select('*', { count: 'exact', head: true }),
supabase
.from('orders')
.select('id, status, total, created_at')
.order('created_at', { ascending: false })
.limit(5),
supabase.rpc('get_order_stats'),
]);
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{/* Stats Grid */}
<div className="grid grid-cols-4 gap-4 mb-8">
<StatCard title="Products" value={productCount || 0} />
<StatCard title="Total Orders" value={orderCount || 0} />
<StatCard title="Revenue" value={`$${orderStats?.total_revenue || 0}`} />
<StatCard title="Pending Orders" value={orderStats?.pending_count || 0} />
</div>
{/* Recent Orders */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Recent Orders</h2>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Order ID</th>
<th className="text-left py-2">Status</th>
<th className="text-left py-2">Total</th>
<th className="text-left py-2">Date</th>
</tr>
</thead>
<tbody>
{recentOrders?.map((order) => (
<tr key={order.id} className="border-b">
<td className="py-2">{order.id.slice(0, 8)}...</td>
<td className="py-2">
<span className={`px-2 py-1 rounded text-xs ${
order.status === 'delivered' ? 'bg-green-100 text-green-700' :
order.status === 'pending' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100'
}`}>
{order.status}
</span>
</td>
<td className="py-2">${order.total}</td>
<td className="py-2">
{new Date(order.created_at).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function StatCard({ title, value }: { title: string; value: string | number }) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-gray-500 text-sm">{title}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
);
}
REST API Patterns Used¶
CRUD Operations¶
// CREATE
const { data, error } = await supabase
.from('products')
.insert({ name: 'New Product', price: 99.99 })
.select()
.single();
// READ (list with filters)
const { data, error } = await supabase
.from('products')
.select('*')
.eq('is_active', true)
.ilike('name', '%search%')
.order('created_at', { ascending: false })
.range(0, 9);
// READ (single)
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', productId)
.single();
// UPDATE
const { error } = await supabase
.from('products')
.update({ price: 149.99, stock: 50 })
.eq('id', productId);
// DELETE
const { error } = await supabase
.from('products')
.delete()
.eq('id', productId);
Aggregations via RPC¶
-- Database function for stats
CREATE OR REPLACE FUNCTION get_order_stats()
RETURNS TABLE (
total_revenue NUMERIC,
pending_count BIGINT,
processing_count BIGINT,
delivered_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(SUM(total), 0),
COUNT(*) FILTER (WHERE status = 'pending'),
COUNT(*) FILTER (WHERE status = 'processing'),
COUNT(*) FILTER (WHERE status = 'delivered')
FROM orders;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
Kết quả¶
Features hoàn thành¶
- Admin authentication + authorization
- Product CRUD với search & pagination
- Order management với status updates
- Dashboard với real-time stats
- Responsive design
Chi phí¶
- Supabase Pro: $25/month (for production)
- Cloudflare: Free tier
- Total: $25/month
Lessons Learned¶
What Worked¶
- PostgREST auto-generate API = zero backend code
- RLS = security at database level
- Server Components = fast initial load
- Parallel queries = better performance
Challenges¶
- RLS policy debugging requires care
- Complex queries may need RPC functions
- Client-side vs Server-side data fetching decision