UI Integration
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:
1. 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);
2. 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
More from constellos/claude-code-plugins
feature-sliced design
This skill should be used when the user asks to "implement FSD", "use Feature-Sliced Design", "organize architecture", "structure project folders", "set up FSD layers", "create feature slices", "refactor to FSD", "add FSD structure", or mentions "feature slices", "layered architecture", "FSD methodology", "architectural organization", "views layer", "entities layer", "shared layer", "Next.js with FSD", or "Turborepo FSD structure". Provides comprehensive guidance for implementing Feature-Sliced Design methodology in Next.js applications with custom 'views' layer naming.
34ui wireframing
This skill should be used when the user asks to "create a wireframe", "design a layout", "plan UI structure", "sketch component layout", "create WIREFRAME.md", "mobile-first wireframe", or mentions ASCII wireframes, layout planning, or UI structure before implementation. Provides mobile-first ASCII wireframe methodology for visualizing layouts before code.
6supabase local development
This skill should be used when the user asks to "start supabase locally", "set up local supabase", "run supabase dev", "initialize supabase project", "configure local database", "start local postgres", "use supabase CLI", "generate database types", or needs guidance on local Supabase development, Docker setup, environment configuration, or database migrations.
6ui interaction
This skill should be used when the user asks to "add client interactivity", "implement form validation", "add event handlers", "use client state", "add Zod validation", "implement React hooks", "add local state", "make component interactive", "add form with validation", "use React Hook Form", or needs guidance on client-side events, form handling, optimistic updates, or when to add "use client" directive.
4ui design
This skill should be used when the user asks to "create a component", "build static UI", "design with TypeScript", "use compound components", "implement contract-first UI", "create React component", "build with Shadcn", or mentions TypeScript interfaces for components, compound component patterns, or Server Components. Provides contract-first static UI methodology with compound components.
4issue management
Use this skill when the user wants to "create issue", "update issue", "add labels", "assign issue", "close issue", "link issues", or manage GitHub issue metadata. Provides comprehensive GitHub issue CRUD operations with intelligent context awareness.
3