UI Integration for Next.js with Supabase
Overview
UI Integration handles the server-side integration layer of Next.js applications with Supabase backends. This skill covers implementing Server Actions, writing type-safe Supabase queries, enforcing RLS policies with explicit auth checks, and properly revalidating data after mutations.
Key principles:
-
Defense-in-depth: Always combine RLS policies with explicit auth checks
-
Use "use server" for all server actions
-
Revalidate paths after mutations for fresh data
-
Generate and use TypeScript types for database queries
-
Handle errors gracefully with proper error boundaries
Skill-scoped Context
Official Documentation:
-
Next.js Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
-
Supabase JavaScript Client: https://supabase.com/docs/reference/javascript/introduction
-
Supabase Row Level Security: https://supabase.com/docs/guides/auth/row-level-security
-
Next.js Revalidation: https://nextjs.org/docs/app/building-your-application/caching#revalidatepath
Workflow
Step 1: Define Server Actions
Create server actions in dedicated files or inline with "use server":
// app/actions/posts.ts "use server";
import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; import { z } from "zod";
const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), });
export async function createPost(formData: FormData) { const supabase = await createClient();
// Explicit auth check (defense-in-depth) const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return { error: "Unauthorized" }; }
// Validate input const rawData = { title: formData.get("title"), content: formData.get("content"), };
const parsed = createPostSchema.safeParse(rawData); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; }
// Insert with user_id (RLS will also enforce this) const { data, error } = await supabase .from("posts") .insert({ title: parsed.data.title, content: parsed.data.content, user_id: user.id, }) .select() .single();
if (error) { return { error: error.message }; }
revalidatePath("/posts"); return { data }; }
Step 2: Implement Supabase Queries
Write type-safe queries using generated types:
// lib/supabase/queries.ts import { createClient } from "@/lib/supabase/server"; import type { Database } from "@/lib/supabase/database.types";
type Post = Database["public"]["Tables"]["posts"]["Row"];
export async function getPosts(): Promise<Post[]> { const supabase = await createClient();
const { data, error } = await supabase .from("posts") .select("*") .order("created_at", { ascending: false });
if (error) { console.error("Error fetching posts:", error); return []; }
return data; }
export async function getPostById(id: string): Promise<Post | null> { const supabase = await createClient();
const { data, error } = await supabase .from("posts") .select("*") .eq("id", id) .single();
if (error) { console.error("Error fetching post:", error); return null; }
return data; }
Step 3: Add Error Handling
Implement proper error handling in server actions:
"use server";
import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache";
export type ActionResult<T> = | { success: true; data: T } | { success: false; error: string };
export async function deletePost(postId: string): Promise<ActionResult<void>> { const supabase = await createClient();
// Auth check const { data: { user } } = await supabase.auth.getUser(); if (!user) { return { success: false, error: "You must be logged in to delete posts" }; }
// Verify ownership before delete (explicit check + RLS) const { data: post } = await supabase .from("posts") .select("user_id") .eq("id", postId) .single();
if (!post || post.user_id !== user.id) { return { success: false, error: "You can only delete your own posts" }; }
const { error } = await supabase .from("posts") .delete() .eq("id", postId);
if (error) { return { success: false, error: error.message }; }
revalidatePath("/posts"); return { success: true, data: undefined }; }
Step 4: Connect to UI
Connect server actions to client components:
// app/posts/new/page.tsx import { createPost } from "@/app/actions/posts"; import { PostForm } from "@/components/post-form";
export default function NewPostPage() { return ( <main className="container mx-auto py-8"> <h1 className="text-2xl font-bold mb-4">Create New Post</h1> <PostForm action={createPost} /> </main> ); }
// components/post-form.tsx "use client";
import { useFormStatus } from "react-dom"; import { useActionState } from "react";
function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending} className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50" > {pending ? "Creating..." : "Create Post"} </button> ); }
export function PostForm({ action }: { action: (formData: FormData) => Promise<{ error?: string; data?: unknown }>; }) { const [state, formAction] = useActionState(action, null);
return ( <form action={formAction} className="space-y-4"> {state?.error && ( <div className="bg-red-100 text-red-700 p-3 rounded" role="alert"> {typeof state.error === "string" ? state.error : "Validation failed"} </div> )}
<div>
<label htmlFor="title" className="block font-medium">
Title
</label>
<input
type="text"
id="title"
name="title"
required
className="mt-1 block w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="content" className="block font-medium">
Content
</label>
<textarea
id="content"
name="content"
required
rows={5}
className="mt-1 block w-full rounded border px-3 py-2"
/>
</div>
<SubmitButton />
</form>
); }
Defense-in-Depth Security
RLS Policy + Explicit Auth Check
Always implement both layers of security:
- RLS Policy (Database Level):
-- Enable RLS on table ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only read their own posts CREATE POLICY "Users can read own posts" ON posts FOR SELECT USING (auth.uid() = user_id);
-- Users can only insert posts with their user_id CREATE POLICY "Users can create own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can only update their own posts CREATE POLICY "Users can update own posts" ON posts FOR UPDATE USING (auth.uid() = user_id);
-- Users can only delete their own posts CREATE POLICY "Users can delete own posts" ON posts FOR DELETE USING (auth.uid() = user_id);
- Explicit Auth Check (Application Level):
"use server";
import { createClient } from "@/lib/supabase/server";
export async function updatePost(postId: string, title: string, content: string) { const supabase = await createClient();
// ALWAYS check auth explicitly - don't rely solely on RLS const { data: { user } } = await supabase.auth.getUser(); if (!user) { return { error: "Unauthorized" }; }
// Verify ownership explicitly const { data: existingPost } = await supabase .from("posts") .select("user_id") .eq("id", postId) .single();
if (!existingPost || existingPost.user_id !== user.id) { return { error: "Forbidden: You don't own this post" }; }
// Now perform the update - RLS provides additional protection const { data, error } = await supabase .from("posts") .update({ title, content, updated_at: new Date().toISOString() }) .eq("id", postId) .select() .single();
if (error) { return { error: error.message }; }
return { data }; }
Type-Safe Database Queries
Generate Types
Generate TypeScript types from your Supabase schema:
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > lib/supabase/database.types.ts
Use Generated Types
import { createClient } from "@/lib/supabase/server"; import type { Database } from "@/lib/supabase/database.types";
type Tables = Database["public"]["Tables"]; type Post = Tables["posts"]["Row"]; type PostInsert = Tables["posts"]["Insert"]; type PostUpdate = Tables["posts"]["Update"];
export async function createPost(post: PostInsert): Promise<Post | null> { const supabase = await createClient();
const { data, error } = await supabase .from("posts") .insert(post) .select() .single();
if (error) { console.error("Error creating post:", error); return null; }
return data; }
Revalidation Patterns
Path Revalidation
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) { // ... create post logic
// Revalidate the posts list page revalidatePath("/posts");
// Revalidate a specific post page
revalidatePath(/posts/${postId});
// Revalidate all pages under /posts revalidatePath("/posts", "layout"); }
Tag Revalidation
import { revalidateTag } from "next/cache";
// In your fetch function, tag the request export async function getPosts() { const supabase = await createClient();
const { data } = await supabase .from("posts") .select("*");
return data; }
// In data fetching component import { unstable_cache } from "next/cache";
const getCachedPosts = unstable_cache( async () => getPosts(), ["posts"], { tags: ["posts"] } );
// In server action, revalidate by tag export async function createPost(formData: FormData) { // ... create post logic
revalidateTag("posts"); }
Complete CRUD Example
// app/actions/todos.ts "use server";
import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; import { z } from "zod";
const todoSchema = z.object({ text: z.string().min(1, "Todo text is required").max(500), });
export async function getTodos() { const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); if (!user) return [];
const { data, error } = await supabase .from("todos") .select("*") .eq("user_id", user.id) .order("created_at", { ascending: false });
if (error) { console.error("Error fetching todos:", error); return []; }
return data; }
export async function createTodo(formData: FormData) { const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); if (!user) return { error: "Unauthorized" };
const parsed = todoSchema.safeParse({ text: formData.get("text") }); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; }
const { error } = await supabase .from("todos") .insert({ text: parsed.data.text, user_id: user.id });
if (error) return { error: error.message };
revalidatePath("/todos"); return { success: true }; }
export async function toggleTodo(id: string, completed: boolean) { const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); if (!user) return { error: "Unauthorized" };
const { error } = await supabase .from("todos") .update({ completed }) .eq("id", id) .eq("user_id", user.id); // Explicit ownership check
if (error) return { error: error.message };
revalidatePath("/todos"); return { success: true }; }
export async function deleteTodo(id: string) { const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); if (!user) return { error: "Unauthorized" };
const { error } = await supabase .from("todos") .delete() .eq("id", id) .eq("user_id", user.id); // Explicit ownership check
if (error) return { error: error.message };
revalidatePath("/todos"); return { success: true }; }
Supabase Client Setup
Server Client
// lib/supabase/server.ts import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import type { Database } from "./database.types";
export async function createClient() { const cookieStore = await cookies();
return createServerClient<Database>( 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 { // Handle cookies in Server Components } }, }, } ); }
Client-Side Client
// lib/supabase/client.ts import { createBrowserClient } from "@supabase/ssr"; import type { Database } from "./database.types";
export function createClient() { return createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); }
Best Practices
DO:
-
Always check authentication in server actions
-
Combine RLS policies with explicit ownership checks
-
Use Zod for input validation on both client and server
-
Generate and use TypeScript types from Supabase
-
Revalidate paths after mutations
-
Handle errors gracefully and return meaningful messages
-
Use useActionState for form state management
DON'T:
-
Rely solely on RLS without application-level checks
-
Trust client-side data without server validation
-
Forget to revalidate after data mutations
-
Expose internal error messages to users
-
Skip ownership verification for update/delete operations
-
Use client components for data fetching
Quick Reference
Server Action Template
"use server";
import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache";
export async function myAction(formData: FormData) { const supabase = await createClient();
// 1. Auth check const { data: { user } } = await supabase.auth.getUser(); if (!user) return { error: "Unauthorized" };
// 2. Validate input // 3. Perform database operation // 4. Revalidate path // 5. Return result }
Common Query Patterns
Operation Pattern
Select all .from("table").select("*")
Select with filter .from("table").select("*").eq("column", value)
Select single .from("table").select("*").eq("id", id).single()
Insert .from("table").insert(data).select().single()
Update .from("table").update(data).eq("id", id)
Delete .from("table").delete().eq("id", id)
Order .order("created_at", { ascending: false })
Limit .limit(10)
Implementation Workflow
To add backend integration:
-
Create or update Supabase table with RLS policies
-
Generate TypeScript types from Supabase schema
-
Create server client setup in lib/supabase/server.ts
-
Define server actions with "use server" directive
-
Add explicit auth checks in every server action
-
Validate input with Zod schemas
-
Implement database queries with proper error handling
-
Add revalidatePath after mutations
-
Connect to UI components via form actions or callbacks
-
Test authentication and authorization thoroughly