Help us improve
Share bugs, ideas, or general feedback.
From nextjs-supabase-ai-sdk-dev
Implements Server Actions, type-safe Supabase queries, RLS enforcement with auth checks, and path revalidation for secure Next.js-Supabase integration.
npx claudepluginhub constellos/claude-code --plugin nextjs-supabase-ai-sdk-devHow this skill is triggered — by the user, by Claude, or both
Slash command
/nextjs-supabase-ai-sdk-dev:ui-integrationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
Implements Supabase clients for Next.js SSR server/client components, React/Vue SPAs, React Native mobile, serverless Edge Functions, and multi-tenant RLS/schema isolation.
Integrates Next.js App Router with Supabase: dual-client auth, middleware token refresh, RLS policies, typed queries, and Server Actions.
Guides using Next.js Server Components for data fetching, direct DB access, Server vs Client decisions, and Server Actions in data-intensive apps.
Share bugs, ideas, or general feedback.
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:
Official Documentation:
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 };
}
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;
}
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 };
}
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>
);
}
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 };
}
Generate TypeScript types from your Supabase schema:
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > lib/supabase/database.types.ts
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;
}
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");
}
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");
}
// 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 };
}
// 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
}
},
},
}
);
}
// 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!
);
}
DO:
DON'T:
"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
}
| 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) |
To add backend integration: