Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -0,0 +1,209 @@
---
status: "published"
description: "Learn how to set up captcha protection for your API routes."
title: "Captcha Protection for your API Routes"
label: "Captcha Protection"
order: 7
---
For captcha protection, we use [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile).
{% sequence title="How to set up captcha protection for your API routes" description="Learn how to set up captcha protection for your API routes" %}
[Setting up the environment variables](#setting-up-the-environment-variables)
[Enabling the captcha protection](#enabling-the-captcha-protection)
[Using captcha in your components](#using-captcha-in-your-components)
[Verifying the token](#verifying-the-token)
{% /sequence %}
## Setting up the environment variables
To enable it, you need to set the following environment variables:
```bash
CAPTCHA_SECRET_TOKEN=
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
```
You can find the `CAPTCHA_SECRET_TOKEN` in the Turnstile configuration. The `NEXT_PUBLIC_CAPTCHA_SITE_KEY` is public and safe to share. Instead, the `CAPTCHA_SECRET_TOKEN` should be kept secret.
This guide assumes you have correctly set up your Turnstile configuration. If you haven't, please refer to the https://developers.cloudflare.com/turnstile.
## Enabling the captcha protection
When you set the token in the environment variables, the kit will automatically protect your API routes with captcha.
**Important:** You also need to set the token in the Supabase Dashboard!
## Using Captcha in Your Components
The kit provides two clean APIs for captcha integration depending on your use case.
### Option 1: Using the useCaptcha Hook
For auth containers and simple forms, use the useCaptcha hook for zero-boilerplate captcha integration:
```tsx
import { useCaptcha } from '@kit/auth/captcha/client';
function MyComponent({ captchaSiteKey }) {
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const handleSubmit = async (data) => {
try {
await myServerAction({
...data,
captchaToken: captcha.token,
});
} finally {
// Always reset after submission
captcha.reset();
}
};
return (
<form onSubmit={handleSubmit}>
{captcha.field}
<button type="submit">Submit</button>
</form>
);
}
```
The useCaptcha hook returns:
- `token` - The current captcha token
- `reset()` - Function to reset the captcha widget
- `field` - The captcha component to render
### Option 2: React Hook Form Integration
For forms using react-hook-form, use the CaptchaField component with automatic form integration:
```tsx
import { useForm } from 'react-hook-form';
import { CaptchaField } from '@kit/auth/captcha/client';
function MyForm() {
const form = useForm({
defaultValues: {
message: '',
captchaToken: '',
},
});
const handleSubmit = async (data) => {
try {
await myServerAction(data);
form.reset(); // Automatically resets captcha too
} catch (error) {
// Handle error
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Your form fields */}
<CaptchaField
siteKey={config.captchaSiteKey}
control={form.control}
name="captchaToken"
/>
<button type="submit">Submit</button>
</form>
</Form>
);
}
```
When using React Hook Form integration:
- The captcha token is automatically set in the form state
- Calling form.reset() automatically resets the captcha
- No manual state management needed
## Using with Server Actions
Define your server action schema to include the captchaToken:
```tsx
import * as z from 'zod';
import { captchaActionClient } from '@kit/next/safe-action';
const MySchema = z.object({
message: z.string(),
captchaToken: z.string(),
});
export const myServerAction = captchaActionClient
.inputSchema(MySchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// Your action code - captcha is automatically verified
console.log(data.message);
});
```
The `captchaActionClient` automatically:
1. Extracts the `captchaToken` from the data
2. Verifies it with Cloudflare Turnstile
3. Throws an error if verification fails
### Important Notes
- **Token Validity**: A captcha token is valid for one request only
- **Always Reset:** Always call captcha.reset() (or form.reset() with RHF) after submission, whether successful or not
- **Automatic Renewal**: The library automatically renews tokens when needed, but you must reset after consumption
## Verifying the Token Manually
If you need to verify the captcha token manually server-side (e.g., in API routes), use:
```tsx
import { verifyCaptchaToken } from '@kit/auth/captcha/server';
async function myApiHandler(request: Request) {
const token = request.headers.get('x-captcha-token');
// Throws an error if invalid
await verifyCaptchaToken(token);
// Your API logic
}
```
Note: If you use `captchaActionClient` or `enhanceRouteHandler` with captcha: true, verification is automatic and you don't need to call verifyCaptchaToken manually.
## Upgrading from v2
{% callout title="Differences with v2" %}
In v2, captcha-protected actions used `enhanceAction` with `{ captcha: true }`. In v3, use `captchaActionClient` from `@kit/next/safe-action` which handles captcha verification automatically. Zod imports also changed from `import { z } from 'zod'` to `import * as z from 'zod'`.
For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration).
{% /callout %}
## Migration from old API (prior to v2.18.3)
If you're migrating from the old `useCaptchaToken` hook:
Before:
```tsx
import { useCaptchaToken } from '@kit/auth/captcha/client';
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
// Manual state management required
```
After:
```tsx
import { useCaptcha } from '@kit/auth/captcha/client';
const captcha = useCaptcha({ siteKey: captchaSiteKey });
```

View File

@@ -0,0 +1,32 @@
---
status: "published"
title: "CSRF Protection"
description: "How CSRF protection works in Makerkit."
label: "CSRF Protection"
order: 6
---
## CSRF Protection
CSRF protection is handled automatically by Next.js when using Server Actions. You do not need to manage CSRF tokens manually.
### Server Actions
Server Actions are inherently protected against CSRF attacks by Next.js. The framework validates the origin of all Server Action requests, ensuring they come from the same origin as your application.
No additional configuration or token passing is needed.
### API Route Handlers
API Route Handlers under `/api/*` do not have CSRF protection, as they are typically used for webhooks, external services, and third-party integrations. If you need to protect an API route from unauthorized access, use authentication checks via `enhanceRouteHandler` with `auth: true`.
### Recommendations
- **Prefer Server Actions** for all mutations from client components. They provide built-in CSRF protection and type safety.
- **Use Route Handlers** only for webhooks, streaming responses, or integrations that require standard HTTP endpoints.
---
## V2 Legacy
In v2, Makerkit used `@edge-csrf/nextjs` middleware to protect non-API routes against CSRF attacks. A `useCsrfToken` hook from `@kit/shared/hooks` was used to retrieve the CSRF token and pass it as an `X-CSRF-Token` header on fetch requests. Both have been removed in v3 since Server Actions handle CSRF protection natively.

View File

@@ -0,0 +1,713 @@
---
status: "published"
title: "Client-Side Data Fetching with React Query"
label: "React Query"
description: "Use React Query (TanStack Query) for client-side data fetching in MakerKit. Covers queries, mutations, caching, optimistic updates, and combining with Server Components."
order: 5
---
React Query (TanStack Query v5) manages client-side data fetching with automatic caching, background refetching, and optimistic updates. MakerKit includes it pre-configured. Use React Query when you need real-time dashboards, infinite scroll, optimistic UI updates, or data shared across multiple components. For initial page loads, prefer Server Components. Tested with TanStack Query v5 (uses `gcTime` instead of `cacheTime`).
### When to use React Query and Server Components?
**Use React Query** for real-time updates, optimistic mutations, pagination, and shared client-side state.
**Use Server Components** for initial page loads and SEO content. Combine both: load data server-side, then hydrate React Query for client interactivity.
## When to Use React Query
**Use React Query for:**
- Real-time dashboards that need background refresh
- Infinite scroll and pagination
- Data that multiple components share
- Optimistic updates for instant feedback
- Client-side filtering and sorting with server data
**Use Server Components instead for:**
- Initial page loads
- SEO-critical content
- Data that doesn't need real-time updates
## Basic Query
Fetch data with `useQuery`. The query automatically caches results and handles loading/error states:
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function TasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const { data: tasks, isLoading, error } = useQuery({
queryKey: ['tasks', accountId],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
});
if (isLoading) {
return <div>Loading tasks...</div>;
}
if (error) {
return <div>Failed to load tasks</div>;
}
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Query Keys
Query keys identify cached data. Structure them hierarchically for easy invalidation:
```tsx
// Specific task
queryKey: ['tasks', taskId]
// All tasks for an account
queryKey: ['tasks', { accountId }]
// All tasks for an account with filters
queryKey: ['tasks', { accountId, status: 'pending', page: 1 }]
// Invalidate all task queries
queryClient.invalidateQueries({ queryKey: ['tasks'] });
// Invalidate tasks for specific account
queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });
```
### Query Key Factory
For larger apps, create a query key factory:
```tsx
// lib/query-keys.ts
export const queryKeys = {
tasks: {
all: ['tasks'] as const,
list: (accountId: string) => ['tasks', { accountId }] as const,
detail: (taskId: string) => ['tasks', taskId] as const,
filtered: (accountId: string, filters: TaskFilters) =>
['tasks', { accountId, ...filters }] as const,
},
members: {
all: ['members'] as const,
list: (accountId: string) => ['members', { accountId }] as const,
},
};
// Usage
const { data } = useQuery({
queryKey: queryKeys.tasks.list(accountId),
queryFn: () => fetchTasks(accountId),
});
// Invalidate all tasks
queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all });
```
## Mutations
Use `useMutation` for create, update, and delete operations:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newTask: { title: string }) => {
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTask.title,
account_id: accountId,
})
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: () => {
// Invalidate and refetch tasks list
queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({ title: formData.get('title') as string });
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Task title" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Task'}
</button>
{mutation.error && (
<p className="text-destructive">Failed to create task</p>
)}
</form>
);
}
```
## Optimistic Updates
Update the UI immediately before the server responds for a snappier feel:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function useUpdateTask(accountId: string) {
const supabase = useSupabase();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (task: { id: string; completed: boolean }) => {
const { data, error } = await supabase
.from('tasks')
.update({ completed: task.completed })
.eq('id', task.id)
.select()
.single();
if (error) throw error;
return data;
},
// Optimistically update the cache
onMutate: async (updatedTask) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ['tasks', { accountId }],
});
// Snapshot previous value
const previousTasks = queryClient.getQueryData<Task[]>([
'tasks',
{ accountId },
]);
// Optimistically update
queryClient.setQueryData<Task[]>(
['tasks', { accountId }],
(old) =>
old?.map((task) =>
task.id === updatedTask.id
? { ...task, completed: updatedTask.completed }
: task
)
);
// Return context with snapshot
return { previousTasks };
},
// Rollback on error
onError: (err, updatedTask, context) => {
queryClient.setQueryData(
['tasks', { accountId }],
context?.previousTasks
);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
},
});
}
```
Usage:
```tsx
function TaskItem({ task, accountId }: { task: Task; accountId: string }) {
const updateTask = useUpdateTask(accountId);
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={task.completed}
onChange={(e) =>
updateTask.mutate({ id: task.id, completed: e.target.checked })
}
/>
<span className={task.completed ? 'line-through' : ''}>
{task.title}
</span>
</label>
);
}
```
## Combining with Server Components
Load initial data in Server Components, then hydrate React Query for client-side updates:
```tsx
// app/tasks/page.tsx (Server Component)
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { TasksManager } from './tasks-manager';
export default async function TasksPage({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account } = await params;
const supabase = getSupabaseServerClient();
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.eq('account_slug', account)
.order('created_at', { ascending: false });
return (
<TasksManager
accountSlug={account}
initialTasks={tasks ?? []}
/>
);
}
```
```tsx
// tasks-manager.tsx (Client Component)
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
interface Props {
accountSlug: string;
initialTasks: Task[];
}
export function TasksManager({ accountSlug, initialTasks }: Props) {
const supabase = useSupabase();
const { data: tasks } = useQuery({
queryKey: ['tasks', { accountSlug }],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_slug', accountSlug)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
// Use server data as initial value
initialData: initialTasks,
// Consider fresh for 30 seconds (skip immediate refetch)
staleTime: 30_000,
});
return (
<div>
{/* tasks is initialTasks on first render, then live data */}
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
```
## Caching Configuration
Control how long data stays fresh and when to refetch:
```tsx
const { data } = useQuery({
queryKey: ['tasks', accountId],
queryFn: fetchTasks,
// Data considered fresh for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 30 minutes
gcTime: 30 * 60 * 1000,
// Refetch when window regains focus
refetchOnWindowFocus: true,
// Refetch every 60 seconds
refetchInterval: 60_000,
// Only refetch interval when tab is visible
refetchIntervalInBackground: false,
});
```
### Global Defaults
Set defaults for all queries in your QueryClient:
```tsx
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: true,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
```
## Pagination
Implement paginated queries:
```tsx
'use client';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useState } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
const PAGE_SIZE = 10;
export function PaginatedTasks({ accountId }: { accountId: string }) {
const [page, setPage] = useState(0);
const supabase = useSupabase();
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['tasks', { accountId, page }],
queryFn: async () => {
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, error, count } = await supabase
.from('tasks')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
return { tasks: data, total: count ?? 0 };
},
// Keep previous data while fetching next page
placeholderData: keepPreviousData,
});
const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE);
return (
<div>
<ul className={isPlaceholderData ? 'opacity-50' : ''}>
{data?.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<div className="flex gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<span>
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages - 1}
>
Next
</button>
</div>
</div>
);
}
```
## Infinite Scroll
For infinite scrolling lists:
```tsx
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
const PAGE_SIZE = 20;
export function InfiniteTasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['tasks', { accountId, infinite: true }],
queryFn: async ({ pageParam }) => {
const from = pageParam * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
return data;
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// Return undefined when no more pages
return lastPage.length === PAGE_SIZE ? allPages.length : undefined;
},
});
const tasks = data?.pages.flatMap((page) => page) ?? [];
return (
<div>
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
```
## Real-Time with Supabase Subscriptions
Combine React Query with Supabase real-time for live updates:
```tsx
'use client';
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function LiveTasks({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const queryClient = useQueryClient();
const { data: tasks } = useQuery({
queryKey: ['tasks', { accountId }],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId);
if (error) throw error;
return data;
},
});
// Subscribe to real-time changes
useEffect(() => {
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `account_id=eq.${accountId}`,
},
() => {
// Invalidate and refetch on any change
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, queryClient, accountId]);
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Using Server Actions with React Query
Combine Server Actions with React Query mutations:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTask } from './actions'; // Server Action
export function useCreateTask(accountId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTask, // Server Action as mutation function
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
},
});
}
// Usage
function CreateTaskForm({ accountId }: { accountId: string }) {
const createTask = useCreateTask(accountId);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createTask.mutate({
title: formData.get('title') as string,
accountId,
});
}}
>
<input name="title" required />
<button disabled={createTask.isPending}>
{createTask.isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}
```
## Common Mistakes
### Forgetting 'use client'
```tsx
// WRONG: React Query hooks require client components
export function Tasks() {
const { data } = useQuery({ ... }); // Error: hooks can't run on server
}
// RIGHT: Mark as client component
'use client';
export function Tasks() {
const { data } = useQuery({ ... });
}
```
### Unstable Query Keys
```tsx
// WRONG: New object reference on every render causes infinite refetches
const { data } = useQuery({
queryKey: ['tasks', { accountId, filters: { status: 'pending' } }],
queryFn: fetchTasks,
});
// RIGHT: Use stable references
const filters = useMemo(() => ({ status: 'pending' }), []);
const { data } = useQuery({
queryKey: ['tasks', { accountId, ...filters }],
queryFn: fetchTasks,
});
// OR: Spread primitive values directly
const { data } = useQuery({
queryKey: ['tasks', accountId, 'pending'],
queryFn: fetchTasks,
});
```
### Not Handling Loading States
```tsx
// WRONG: Assuming data exists
function Tasks() {
const { data } = useQuery({ ... });
return <ul>{data.map(...)}</ul>; // data might be undefined
}
// RIGHT: Handle all states
function Tasks() {
const { data, isLoading, error } = useQuery({ ... });
if (isLoading) return <Skeleton />;
if (error) return <Error />;
if (!data?.length) return <Empty />;
return <ul>{data.map(...)}</ul>;
}
```
## Next Steps
- [Server Components](server-components) - Initial data loading
- [Server Actions](server-actions) - Mutations with Server Actions
- [Supabase Clients](supabase-clients) - Browser vs server clients

View File

@@ -0,0 +1,567 @@
---
status: "published"
title: "API Route Handlers in Next.js"
label: "Route Handlers"
description: "Build API endpoints with Next.js Route Handlers. Covers the enhanceRouteHandler utility, webhook handling, CSRF protection, and when to use Route Handlers vs Server Actions."
order: 2
---
[Route Handlers](/blog/tutorials/server-actions-vs-route-handlers) create HTTP API endpoints in Next.js by exporting functions named GET, POST, PUT, or DELETE from a `route.ts` file.
While Server Actions handle most mutations, Route Handlers are essential for webhooks (Stripe, Lemon Squeezy), external API access, streaming responses, and scenarios needing custom HTTP headers or status codes.
MakerKit's `enhanceRouteHandler` adds authentication and validation. Tested with Next.js 16 (async headers/params).
{% callout title="When to use Route Handlers" %}
**Use Route Handlers** for webhooks, external services calling your API, streaming responses, and public APIs. **Use Server Actions** for mutations from your own app (forms, button clicks).
{% /callout %}
## When to Use Route Handlers
**Use Route Handlers for:**
- Webhook endpoints (Stripe, Lemon Squeezy, GitHub, etc.)
- External services calling your API
- Public APIs for third-party consumption
- Streaming responses or Server-Sent Events
- Custom headers, status codes, or response formats
**Use Server Actions instead for:**
- Form submissions from your own app
- Mutations triggered by user interactions
- Any operation that doesn't need HTTP details
## Basic Route Handler
Create a `route.ts` file in any route segment:
```tsx
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
}
```
This creates an endpoint at `/api/health` that responds to GET requests.
### HTTP Methods
Export functions named after HTTP methods:
```tsx
// app/api/tasks/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
// Handle GET /api/tasks
}
export async function POST(request: Request) {
// Handle POST /api/tasks
}
export async function PUT(request: Request) {
// Handle PUT /api/tasks
}
export async function DELETE(request: Request) {
// Handle DELETE /api/tasks
}
```
## Using enhanceRouteHandler
The `enhanceRouteHandler` utility adds authentication, validation, and captcha verification:
```tsx
import { NextResponse } from 'next/server';
import * as z from 'zod';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const POST = enhanceRouteHandler(
async ({ body, user, request }) => {
// body is validated against the schema
// user is the authenticated user
// request is the original NextRequest
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.insert({
title: body.title,
account_id: body.accountId,
created_by: user.id,
})
.select()
.single();
if (error) {
return NextResponse.json(
{ error: 'Failed to create task' },
{ status: 500 }
);
}
return NextResponse.json({ task: data }, { status: 201 });
},
{
schema: CreateTaskSchema,
auth: true, // Require authentication (default)
}
);
```
### Configuration Options
```tsx
enhanceRouteHandler(handler, {
// Zod schema for request body validation
schema: MySchema,
// Require authentication (default: true)
auth: true,
// Require captcha verification (default: false)
captcha: false,
});
```
### Public Endpoints
For public endpoints (no authentication required):
```tsx
export const GET = enhanceRouteHandler(
async ({ request }) => {
// user will be undefined
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('public_content')
.select('*')
.limit(10);
return NextResponse.json({ content: data });
},
{ auth: false }
);
```
## Dynamic Route Parameters
Access route parameters in Route Handlers:
```tsx
// app/api/tasks/[id]/route.ts
import { NextResponse } from 'next/server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const GET = enhanceRouteHandler(
async ({ user, params }) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('id', params.id)
.single();
if (error || !data) {
return NextResponse.json(
{ error: 'Task not found' },
{ status: 404 }
);
}
return NextResponse.json({ task: data });
},
{ auth: true }
);
export const DELETE = enhanceRouteHandler(
async ({ user, params }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', params.id)
.eq('created_by', user.id);
if (error) {
return NextResponse.json(
{ error: 'Failed to delete task' },
{ status: 500 }
);
}
return new Response(null, { status: 204 });
},
{ auth: true }
);
```
## Webhook Handling
Webhooks require special handling since they come from external services without user authentication.
### Stripe Webhook Example
```tsx
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// Use admin client since webhooks don't have user context
const supabase = getSupabaseServerAdminClient();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await supabase
.from('subscriptions')
.update({ status: 'active' })
.eq('stripe_customer_id', session.customer);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await supabase
.from('subscriptions')
.update({ status: 'cancelled' })
.eq('stripe_subscription_id', subscription.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
```
### Generic Webhook Pattern
```tsx
// app/api/webhooks/[provider]/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
type WebhookHandler = {
verifySignature: (body: string, signature: string) => boolean;
handleEvent: (event: unknown) => Promise<void>;
};
const handlers: Record<string, WebhookHandler> = {
stripe: {
verifySignature: (body, sig) => { /* ... */ },
handleEvent: async (event) => { /* ... */ },
},
github: {
verifySignature: (body, sig) => { /* ... */ },
handleEvent: async (event) => { /* ... */ },
},
};
export async function POST(
request: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
const handler = handlers[provider];
if (!handler) {
return NextResponse.json(
{ error: 'Unknown provider' },
{ status: 404 }
);
}
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('x-signature') ?? '';
if (!handler.verifySignature(body, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
try {
const event = JSON.parse(body);
await handler.handleEvent(event);
return NextResponse.json({ received: true });
} catch (error) {
console.error(`Webhook error (${provider}):`, error);
return NextResponse.json(
{ error: 'Processing failed' },
{ status: 500 }
);
}
}
```
## CSRF Protection
CSRF protection is handled natively by Next.js Server Actions. No manual CSRF token management is needed.
Routes under `/api/*` are intended for external access (webhooks, third-party integrations) and do not have CSRF protection. Use authentication checks via `enhanceRouteHandler` with `auth: true` if needed.
## Streaming Responses
Route Handlers support streaming for real-time data:
```tsx
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = JSON.stringify({ count: i, timestamp: Date.now() });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
```
## File Uploads
Handle file uploads with Route Handlers:
```tsx
// app/api/upload/route.ts
import { NextResponse } from 'next/server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const POST = enhanceRouteHandler(
async ({ request, user }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type' },
{ status: 400 }
);
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: 'File too large' },
{ status: 400 }
);
}
const supabase = getSupabaseServerClient();
const fileName = `${user.id}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage
.from('uploads')
.upload(fileName, file);
if (error) {
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
);
}
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(fileName);
return NextResponse.json({
url: urlData.publicUrl,
});
},
{ auth: true }
);
```
## Error Handling
### Consistent Error Responses
Create a helper for consistent error responses:
```tsx
// lib/api-errors.ts
import { NextResponse } from 'next/server';
export function apiError(
message: string,
status: number = 500,
details?: Record<string, unknown>
) {
return NextResponse.json(
{
error: message,
...details,
},
{ status }
);
}
export function notFound(resource: string = 'Resource') {
return apiError(`${resource} not found`, 404);
}
export function unauthorized(message: string = 'Unauthorized') {
return apiError(message, 401);
}
export function badRequest(message: string, field?: string) {
return apiError(message, 400, field ? { field } : undefined);
}
```
Usage:
```tsx
import { notFound, badRequest } from '@/lib/api-errors';
export const GET = enhanceRouteHandler(
async ({ params }) => {
const task = await getTask(params.id);
if (!task) {
return notFound('Task');
}
return NextResponse.json({ task });
},
{ auth: true }
);
```
## Route Handler vs Server Action
| Scenario | Use |
|----------|-----|
| Form submission from your app | Server Action |
| Button click triggers mutation | Server Action |
| Webhook from Stripe/GitHub | Route Handler |
| External service needs your API | Route Handler |
| Need custom status codes | Route Handler |
| Need streaming response | Route Handler |
| Need to set specific headers | Route Handler |
## Common Mistakes
### Forgetting to Verify Webhook Signatures
```tsx
// WRONG: Trusting webhook data without verification
export async function POST(request: Request) {
const event = await request.json();
await processEvent(event); // Anyone can call this!
}
// RIGHT: Verify signature before processing
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-signature');
if (!verifySignature(body, signature)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
await processEvent(event);
}
```
### Using Wrong Client in Webhooks
```tsx
// WRONG: Regular client in webhook (no user session)
export async function POST(request: Request) {
const supabase = getSupabaseServerClient();
// This will fail - no user session for RLS
await supabase.from('subscriptions').update({ ... });
}
// RIGHT: Admin client for webhook operations
export async function POST(request: Request) {
// Verify signature first!
const supabase = getSupabaseServerAdminClient();
await supabase.from('subscriptions').update({ ... });
}
```
## Next Steps
- [Server Actions](server-actions) - For mutations from your app
- [CSRF Protection](csrf-protection) - Secure your endpoints
- [Captcha Protection](captcha-protection) - Bot protection

View File

@@ -0,0 +1,816 @@
---
status: "published"
title: "Server Actions for Data Mutations"
label: "Server Actions"
description: "Use Server Actions to handle form submissions and data mutations in MakerKit. Covers authActionClient, validation, authentication, revalidation, and captcha protection."
order: 1
---
Server Actions are async functions marked with `'use server'` that run on the server but can be called directly from client components. They handle form submissions, data mutations, and any operation that modifies your database. MakerKit's `authActionClient` adds authentication and Zod validation with zero boilerplate, while `publicActionClient` and `captchaActionClient` handle public and captcha-protected actions respectively. Tested with Next.js 16 and React 19.
{% callout title="When to use Server Actions" %}
**Use Server Actions** for any mutation: form submissions, button clicks that create/update/delete data, and operations needing server-side validation. Use Route Handlers only for webhooks and external API access.
{% /callout %}
## Basic Server Action
A Server Action is any async function in a file marked with `'use server'`:
```tsx
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function createTask(formData: FormData) {
const supabase = getSupabaseServerClient();
const title = formData.get('title') as string;
const { error } = await supabase.from('tasks').insert({ title });
if (error) {
return { success: false, error: error.message };
}
return { success: true };
}
```
This works, but lacks validation, authentication, and proper error handling. The action clients solve these problems.
## Using authActionClient
The `authActionClient` creates type-safe, validated server actions with built-in authentication:
1. **Authentication** - Verifies the user is logged in
2. **Validation** - Validates input against a Zod schema
3. **Type Safety** - Full end-to-end type inference
```tsx
'use server';
import * as z from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().optional(),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// data is typed and validated
// user is the authenticated user
const supabase = getSupabaseServerClient();
const { error } = await supabase.from('tasks').insert({
title: data.title,
description: data.description,
account_id: data.accountId,
created_by: user.id,
});
if (error) {
throw new Error('Failed to create task');
}
return { success: true };
});
```
### Available Action Clients
| Client | Import | Use Case |
|--------|--------|----------|
| `authActionClient` | `@kit/next/safe-action` | Requires authenticated user (most common) |
| `publicActionClient` | `@kit/next/safe-action` | No auth required (contact forms, etc.) |
| `captchaActionClient` | `@kit/next/safe-action` | Requires CAPTCHA + auth |
### Public Actions
For public actions (like contact forms), use `publicActionClient`:
```tsx
'use server';
import * as z from 'zod';
import { publicActionClient } from '@kit/next/safe-action';
const ContactFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
});
export const submitContactForm = publicActionClient
.inputSchema(ContactFormSchema)
.action(async ({ parsedInput: data }) => {
// No user context - this is a public action
await sendEmail(data);
return { success: true };
});
```
## Calling Server Actions from Components
### With useAction (Recommended)
The `useAction` hook from `next-safe-action/hooks` is the primary way to call server actions from client components:
```tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { createTask } from './actions';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const { execute, isPending } = useAction(createTask, {
onSuccess: ({ data }) => {
// Handle success
},
onError: ({ error }) => {
// Handle error
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
title: formData.get('title') as string,
accountId,
});
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" placeholder="Task title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
);
}
```
### With useActionState (React 19)
`useActionState` works with plain Server Actions (not `next-safe-action` wrapped actions). Define a plain action for this pattern:
```tsx
// actions.ts
'use server';
export async function createTaskFormAction(prevState: unknown, formData: FormData) {
const title = formData.get('title') as string;
const accountId = formData.get('accountId') as string;
// validate and create task...
return { success: true };
}
```
```tsx
'use client';
import { useActionState } from 'react';
import { createTaskFormAction } from './actions';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const [state, formAction, isPending] = useActionState(createTaskFormAction, null);
return (
<form action={formAction}>
<input type="hidden" name="accountId" value={accountId} />
<input type="text" name="title" placeholder="Task title" />
{state?.error && (
<p className="text-destructive">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
);
}
```
{% alert type="info" %}
`useActionState` expects a plain server action with signature `(prevState, formData) => newState`. For `next-safe-action` wrapped actions, use the `useAction` hook from `next-safe-action/hooks` instead.
{% /alert %}
### Direct Function Calls
Call Server Actions directly for more complex scenarios:
```tsx
'use client';
import { useState, useTransition } from 'react';
import { createTask } from './actions';
export function CreateTaskButton({ accountId }: { accountId: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleClick = () => {
startTransition(async () => {
try {
const result = await createTask({
title: 'New Task',
accountId,
});
if (!result?.data?.success) {
setError('Failed to create task');
}
} catch (e) {
setError('An unexpected error occurred');
}
});
};
return (
<>
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Creating...' : 'Quick Add Task'}
</button>
{error && <p className="text-destructive">{error}</p>}
</>
);
}
```
## Revalidating Data
After mutations, revalidate cached data so the UI reflects changes:
### Revalidate by Path
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').insert({ /* ... */ });
// Revalidate the tasks page
revalidatePath('/tasks');
// Or revalidate with layout
revalidatePath('/tasks', 'layout');
return { success: true };
});
```
### Revalidate by Tag
For more granular control, use cache tags:
```tsx
'use server';
import * as z from 'zod';
import { revalidateTag } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const UpdateTaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
});
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').update(data).eq('id', data.id);
// Revalidate all queries tagged with 'tasks'
revalidateTag('tasks');
// Or revalidate specific task
revalidateTag(`task-${data.id}`);
return { success: true };
});
```
### Redirecting After Mutation
```tsx
'use server';
import * as z from 'zod';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { data: task } = await supabase
.from('tasks')
.insert({ /* ... */ })
.select('id')
.single();
// Redirect to the new task
redirect(`/tasks/${task.id}`);
});
```
## Error Handling
### Returning Errors
Return structured errors for the client to handle:
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Check for duplicate title
const { data: existing } = await supabase
.from('tasks')
.select('id')
.eq('title', data.title)
.eq('account_id', data.accountId)
.single();
if (existing) {
return {
success: false,
error: 'A task with this title already exists',
};
}
const { error } = await supabase.from('tasks').insert({ /* ... */ });
if (error) {
// Log for debugging, return user-friendly message
console.error('Failed to create task:', error);
return {
success: false,
error: 'Failed to create task. Please try again.',
};
}
revalidatePath('/tasks');
return { success: true };
});
```
### Throwing Errors
For unexpected errors, throw to trigger error boundaries:
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const DeleteTaskSchema = z.object({
taskId: z.string().uuid(),
});
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', data.taskId)
.eq('created_by', user.id); // Ensure ownership
if (error) {
// This will be caught by error boundaries
// and reported to your monitoring provider
throw new Error('Failed to delete task');
}
revalidatePath('/tasks');
return { success: true };
});
```
## Captcha Protection
For sensitive actions, add Cloudflare Turnstile captcha verification:
### Server Action Setup
```tsx
'use server';
import * as z from 'zod';
import { captchaActionClient } from '@kit/next/safe-action';
const TransferFundsSchema = z.object({
amount: z.number().positive(),
toAccountId: z.string().uuid(),
captchaToken: z.string(),
});
export const transferFunds = captchaActionClient
.inputSchema(TransferFundsSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// Captcha is verified before this runs
// ... transfer logic
});
```
### Client Component with Captcha
```tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useCaptcha } from '@kit/auth/captcha/client';
import { transferFunds } from './actions';
export function TransferForm({ captchaSiteKey }: { captchaSiteKey: string }) {
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const { execute, isPending } = useAction(transferFunds, {
onSuccess: () => {
// Handle success
},
onSettled: () => {
// Always reset captcha after submission
captcha.reset();
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
amount: Number(formData.get('amount')),
toAccountId: formData.get('toAccountId') as string,
captchaToken: captcha.token,
});
};
return (
<form onSubmit={handleSubmit}>
<input type="number" name="amount" placeholder="Amount" />
<input type="text" name="toAccountId" placeholder="Recipient" />
{/* Render captcha widget */}
{captcha.field}
<button type="submit" disabled={isPending}>
{isPending ? 'Transferring...' : 'Transfer'}
</button>
</form>
);
}
```
See [Captcha Protection](captcha-protection) for detailed setup instructions.
## Real-World Example: Team Settings
Here's a complete example of Server Actions for team management:
```tsx
// lib/server/team-actions.ts
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getLogger } from '@kit/shared/logger';
const UpdateTeamSchema = z.object({
teamId: z.string().uuid(),
name: z.string().min(2).max(50),
slug: z.string().min(2).max(30).regex(/^[a-z0-9-]+$/),
});
const InviteMemberSchema = z.object({
teamId: z.string().uuid(),
email: z.string().email(),
role: z.enum(['member', 'admin']),
});
const RemoveMemberSchema = z.object({
teamId: z.string().uuid(),
userId: z.string().uuid(),
});
export const updateTeam = authActionClient
.inputSchema(UpdateTeamSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const logger = await getLogger();
const supabase = getSupabaseServerClient();
logger.info({ teamId: data.teamId, userId: user.id }, 'Updating team');
// Check if slug is taken
const { data: existing } = await supabase
.from('accounts')
.select('id')
.eq('slug', data.slug)
.neq('id', data.teamId)
.single();
if (existing) {
return {
success: false,
error: 'This URL is already taken',
field: 'slug',
};
}
const { error } = await supabase
.from('accounts')
.update({ name: data.name, slug: data.slug })
.eq('id', data.teamId);
if (error) {
logger.error({ error, teamId: data.teamId }, 'Failed to update team');
return { success: false, error: 'Failed to update team' };
}
revalidatePath(`/home/${data.slug}/settings`);
return { success: true };
});
export const inviteMember = authActionClient
.inputSchema(InviteMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Check if already a member
const { data: existing } = await supabase
.from('account_members')
.select('id')
.eq('account_id', data.teamId)
.eq('user_email', data.email)
.single();
if (existing) {
return { success: false, error: 'User is already a member' };
}
// Create invitation
const { error } = await supabase.from('invitations').insert({
account_id: data.teamId,
email: data.email,
role: data.role,
invited_by: user.id,
});
if (error) {
return { success: false, error: 'Failed to send invitation' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
export const removeMember = authActionClient
.inputSchema(RemoveMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Prevent removing yourself
if (data.userId === user.id) {
return { success: false, error: 'You cannot remove yourself' };
}
const { error } = await supabase
.from('account_members')
.delete()
.eq('account_id', data.teamId)
.eq('user_id', data.userId);
if (error) {
return { success: false, error: 'Failed to remove member' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
```
## Common Mistakes
### Forgetting to Revalidate
```tsx
// WRONG: Data changes but UI doesn't update
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').update(data).eq('id', data.id);
return { success: true };
});
// RIGHT: Revalidate after mutation
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').update(data).eq('id', data.id);
revalidatePath('/tasks');
return { success: true };
});
```
### Using try/catch Incorrectly
```tsx
// WRONG: Swallowing errors silently
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data }) => {
try {
await supabase.from('tasks').insert(data);
} catch (e) {
// Error is lost, user sees "success"
}
return { success: true };
});
// RIGHT: Return or throw errors
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data }) => {
const { error } = await supabase.from('tasks').insert(data);
if (error) {
return { success: false, error: 'Failed to create task' };
}
return { success: true };
});
```
### Not Validating Ownership
```tsx
// WRONG: Any user can delete any task
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').delete().eq('id', data.taskId);
});
// RIGHT: Verify ownership (or use RLS)
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', data.taskId)
.eq('created_by', user.id); // User can only delete their own tasks
if (error) {
return { success: false, error: 'Task not found or access denied' };
}
return { success: true };
});
```
## Using enhanceAction (Deprecated)
{% callout title="Deprecated" %}
`enhanceAction` is still available but deprecated. Use `authActionClient`, `publicActionClient`, or `captchaActionClient` for new code.
{% /callout %}
The `enhanceAction` utility from `@kit/next/actions` wraps a Server Action with authentication, Zod validation, and optional captcha verification:
```tsx
'use server';
import * as z from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
accountId: z.string().uuid(),
});
// Authenticated action (default)
export const createTask = enhanceAction(
async (data, user) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').insert({
title: data.title,
account_id: data.accountId,
created_by: user.id,
});
return { success: true };
},
{ schema: CreateTaskSchema }
);
// Public action (no auth required)
export const submitContactForm = enhanceAction(
async (data) => {
await sendEmail(data);
return { success: true };
},
{ schema: ContactFormSchema, auth: false }
);
// With captcha verification
export const sensitiveAction = enhanceAction(
async (data, user) => {
// captcha verified before this runs
},
{ schema: MySchema, captcha: true }
);
```
### Configuration Options
```tsx
enhanceAction(handler, {
schema: MySchema, // Zod schema for input validation
auth: true, // Require authentication (default: true)
captcha: false, // Require captcha verification (default: false)
});
```
### Migrating to authActionClient
```tsx
// Before (enhanceAction)
export const myAction = enhanceAction(
async (data, user) => { /* ... */ },
{ schema: MySchema }
);
// After (authActionClient)
export const myAction = authActionClient
.inputSchema(MySchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
/* ... */
});
```
| enhanceAction option | v3 equivalent |
|---------------------|---------------|
| `{ auth: true }` (default) | `authActionClient` |
| `{ auth: false }` | `publicActionClient` |
| `{ captcha: true }` | `captchaActionClient` |
## Next Steps
- [Route Handlers](route-handlers) - For webhooks and external APIs
- [Captcha Protection](captcha-protection) - Protect sensitive actions
- [React Query](react-query) - Combine with optimistic updates

View File

@@ -0,0 +1,487 @@
---
status: "published"
title: "Data Fetching with Server Components"
label: "Server Components"
description: "Load data in Next.js Server Components with Supabase. Covers streaming, Suspense boundaries, parallel data loading, caching, and error handling patterns."
order: 3
---
Server Components fetch data on the server during rendering, streaming HTML directly to the browser without adding to your JavaScript bundle. They're the default for all data loading in MakerKit because they're secure (queries never reach the client), SEO-friendly (content renders for search engines), and fast (no client-side fetching waterfalls). Tested with Next.js 16 and React 19.
{% callout title="When to use Server Components" %}
**Use Server Components** (the default) for page loads, SEO content, and data that doesn't need real-time updates. Only switch to Client Components with React Query when you need optimistic updates, real-time subscriptions, or client-side filtering.
{% /callout %}
## Why Server Components for Data Fetching
Server Components provide significant advantages for data loading:
- **No client bundle impact** - Database queries don't increase JavaScript bundle size
- **Direct database access** - Query Supabase directly without API round-trips
- **Streaming** - Users see content progressively as data loads
- **SEO-friendly** - Content is rendered on the server for search engines
- **Secure by default** - Queries never reach the browser
## Basic Data Fetching
Every component in Next.js is a Server Component by default. Add the `async` keyword to fetch data directly:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TasksPage() {
const supabase = getSupabaseServerClient();
const { data: tasks, error } = await supabase
.from('tasks')
.select('id, title, completed, created_at')
.order('created_at', { ascending: false });
if (error) {
throw new Error('Failed to load tasks');
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Streaming with Suspense
Suspense boundaries let you show loading states while data streams in. This prevents the entire page from waiting for slow queries.
### Page-Level Loading States
Create a `loading.tsx` file next to your page to show a loading UI while the page data loads:
```tsx
// app/tasks/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse bg-muted rounded" />
<div className="h-64 animate-pulse bg-muted rounded" />
</div>
);
}
```
### Component-Level Suspense
For granular control, wrap individual components in Suspense boundaries:
```tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
{/* Stats load first */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* Tasks can load independently */}
<Suspense fallback={<TasksSkeleton />}>
<RecentTasks />
</Suspense>
{/* Activity loads last */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function DashboardStats() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('get_dashboard_stats');
return <StatsDisplay stats={data} />;
}
async function RecentTasks() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.from('tasks').select('*').limit(5);
return <TasksList tasks={data} />;
}
```
## Parallel Data Loading
Load multiple data sources simultaneously to minimize waterfall requests. Use `Promise.all` to fetch in parallel:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function AccountDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account } = await params;
const supabase = getSupabaseServerClient();
// All queries run in parallel
const [tasksResult, membersResult, statsResult] = await Promise.all([
supabase
.from('tasks')
.select('*')
.eq('account_slug', account)
.limit(10),
supabase
.from('account_members')
.select('*, user:users(name, avatar_url)')
.eq('account_slug', account),
supabase.rpc('get_account_stats', { account_slug: account }),
]);
return (
<Dashboard
tasks={tasksResult.data}
members={membersResult.data}
stats={statsResult.data}
/>
);
}
```
### Avoiding Waterfalls
A waterfall occurs when queries depend on each other sequentially:
```tsx
// BAD: Waterfall - each query waits for the previous
async function SlowDashboard() {
const supabase = getSupabaseServerClient();
const { data: account } = await supabase
.from('accounts')
.select('*')
.single();
// This waits for account to load first
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.eq('account_id', account.id);
// This waits for tasks to load
const { data: members } = await supabase
.from('members')
.select('*')
.eq('account_id', account.id);
}
// GOOD: Parallel loading when data is independent
async function FastDashboard({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const [tasks, members] = await Promise.all([
supabase.from('tasks').select('*').eq('account_id', accountId),
supabase.from('members').select('*').eq('account_id', accountId),
]);
}
```
## Caching Strategies
### Request Deduplication
Next.js automatically deduplicates identical fetch requests within a single render. If multiple components need the same data, wrap your data fetching in React's `cache()`:
```tsx
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// This query runs once per request, even if called multiple times
export const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
if (error) throw error;
return data;
});
// Both components can call getAccount('acme') - only one query runs
async function AccountHeader({ slug }: { slug: string }) {
const account = await getAccount(slug);
return <h1>{account.name}</h1>;
}
async function AccountSidebar({ slug }: { slug: string }) {
const account = await getAccount(slug);
return <nav>{/* uses account.settings */}</nav>;
}
```
### Using `unstable_cache` for Persistent Caching
For data that doesn't change often, use Next.js's `unstable_cache` to cache across requests:
```tsx
import { unstable_cache } from 'next/cache';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const getCachedPricingPlans = unstable_cache(
async () => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('pricing_plans')
.select('*')
.eq('active', true);
return data;
},
['pricing-plans'], // Cache key
{
revalidate: 3600, // Revalidate every hour
tags: ['pricing'], // Tag for manual revalidation
}
);
export default async function PricingPage() {
const plans = await getCachedPricingPlans();
return <PricingTable plans={plans} />;
}
```
To invalidate the cache after updates:
```tsx
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePricingPlan(data: PlanUpdate) {
const supabase = getSupabaseServerClient();
await supabase.from('pricing_plans').update(data).eq('id', data.id);
// Invalidate the pricing cache
revalidateTag('pricing');
}
```
## Error Handling
### Error Boundaries
Create an `error.tsx` file to catch errors in your route segment:
```tsx
// app/tasks/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-4 border border-destructive rounded-lg">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-muted-foreground">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded"
>
Try again
</button>
</div>
);
}
```
### Graceful Degradation
For non-critical data, handle errors gracefully instead of throwing:
```tsx
async function OptionalWidget() {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('widgets')
.select('*')
.limit(5);
// Don't crash the page if this fails
if (error || !data?.length) {
return null; // or return a fallback UI
}
return <WidgetList widgets={data} />;
}
```
## Real-World Example: Team Dashboard
Here's a complete example combining multiple patterns:
```tsx
// app/home/[account]/page.tsx
import { Suspense } from 'react';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Cached account loader - reusable across components
const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
return data;
});
export default async function TeamDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account: slug } = await params;
const account = await getAccount(slug);
if (!account) {
notFound();
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{account.name}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Stats stream in first */}
<Suspense fallback={<StatsSkeleton />}>
<AccountStats accountId={account.id} />
</Suspense>
{/* Tasks load independently */}
<Suspense fallback={<TasksSkeleton />}>
<RecentTasks accountId={account.id} />
</Suspense>
</div>
{/* Activity feed can load last */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed accountId={account.id} />
</Suspense>
</div>
);
}
async function AccountStats({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('get_account_stats', {
p_account_id: accountId,
});
return (
<div className="grid grid-cols-3 gap-4">
<StatCard title="Tasks" value={data.total_tasks} />
<StatCard title="Completed" value={data.completed_tasks} />
<StatCard title="Members" value={data.member_count} />
</div>
);
}
async function RecentTasks({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data: tasks } = await supabase
.from('tasks')
.select('id, title, completed, assignee:users(name)')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.limit(5);
return (
<div className="border rounded-lg p-4">
<h2 className="font-semibold mb-4">Recent Tasks</h2>
<ul className="space-y-2">
{tasks?.map((task) => (
<li key={task.id} className="flex items-center gap-2">
<span className={task.completed ? 'line-through' : ''}>
{task.title}
</span>
{task.assignee && (
<span className="text-sm text-muted-foreground">
- {task.assignee.name}
</span>
)}
</li>
))}
</ul>
</div>
);
}
async function ActivityFeed({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data: activities } = await supabase
.from('activity_log')
.select('*, user:users(name)')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.limit(10);
return (
<div className="border rounded-lg p-4">
<h2 className="font-semibold mb-4">Recent Activity</h2>
<ul className="space-y-2">
{activities?.map((activity) => (
<li key={activity.id} className="text-sm">
<span className="font-medium">{activity.user.name}</span>{' '}
{activity.action}
</li>
))}
</ul>
</div>
);
}
```
## When to Use Client Components Instead
Server Components are great for initial page loads, but some scenarios need client components:
- **Real-time updates** - Use React Query with Supabase subscriptions
- **User interactions** - Sorting, filtering, pagination with instant feedback
- **Forms** - Complex forms with validation and state management
- **Optimistic updates** - Update UI before server confirms
For these cases, load initial data in Server Components and pass to client components:
```tsx
// Server Component - loads initial data
export default async function TasksPage() {
const tasks = await loadTasks();
return <TasksManager initialTasks={tasks} />;
}
// Client Component - handles interactivity
'use client';
function TasksManager({ initialTasks }) {
const [tasks, setTasks] = useState(initialTasks);
// ... sorting, filtering, real-time updates
}
```
## Next Steps
- [Server Actions](server-actions) - Mutate data from Server Components
- [React Query](react-query) - Client-side data management
- [Route Handlers](route-handlers) - Build API endpoints

View File

@@ -0,0 +1,354 @@
---
status: "published"
title: "Supabase Clients in Next.js"
label: "Supabase Clients"
description: "How to use Supabase clients in browser and server environments. Includes the standard client, server client, and admin client for bypassing RLS."
order: 0
---
MakerKit provides three Supabase clients for different environments: `useSupabase()` for Client Components, `getSupabaseServerClient()` for Server Components and Server Actions, and `getSupabaseServerAdminClient()` for admin operations that bypass Row Level Security. Use the right client for your context to ensure security and proper RLS enforcement. As of Next.js 16 and React 19, these patterns are tested and recommended.
{% callout title="Which client should I use?" %}
**In Client Components**: Use `useSupabase()` hook. **In Server Components or Server Actions**: Use `getSupabaseServerClient()`. **For webhooks or admin tasks**: Use `getSupabaseServerAdminClient()` (bypasses RLS).
{% /callout %}
## Client Overview
| Client | Environment | RLS | Use Case |
|--------|-------------|-----|----------|
| `useSupabase()` | Browser (React) | Yes | Client components, real-time subscriptions |
| `getSupabaseServerClient()` | Server | Yes | Server Components, Server Actions, Route Handlers |
| `getSupabaseServerAdminClient()` | Server | **Bypassed** | Admin operations, migrations, webhooks |
## Browser Client
Use the `useSupabase` hook in client components. This client runs in the browser and respects all RLS policies.
```tsx
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function TasksList() {
const supabase = useSupabase();
const handleComplete = async (taskId: string) => {
const { error } = await supabase
.from('tasks')
.update({ completed: true })
.eq('id', taskId);
if (error) {
console.error('Failed to complete task:', error.message);
}
};
return (
<button onClick={() => handleComplete('task-123')}>
Complete Task
</button>
);
}
```
### Real-time Subscriptions
The browser client supports real-time subscriptions for live updates:
```tsx
'use client';
import { useEffect, useState } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function LiveTasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
// Subscribe to changes
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `account_id=eq.${accountId}`,
},
(payload) => {
if (payload.eventType === 'INSERT') {
setTasks((prev) => [...prev, payload.new as Task]);
}
if (payload.eventType === 'UPDATE') {
setTasks((prev) =>
prev.map((t) => (t.id === payload.new.id ? payload.new as Task : t))
);
}
if (payload.eventType === 'DELETE') {
setTasks((prev) => prev.filter((t) => t.id !== payload.old.id));
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, accountId]);
return <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>;
}
```
## Server Client
Use `getSupabaseServerClient()` in all server environments: Server Components, Server Actions, and Route Handlers. This is a unified client that works across all server contexts.
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Server Component
export default async function TasksPage() {
const supabase = getSupabaseServerClient();
const { data: tasks, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
if (error) {
throw new Error('Failed to load tasks');
}
return <TasksList tasks={tasks} />;
}
```
### Server Actions
The same client works in Server Actions:
```tsx
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { authActionClient } from '@kit/next/safe-action';
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase.from('tasks').insert({
title: data.title,
account_id: data.accountId,
created_by: user.id,
});
if (error) {
throw new Error('Failed to create task');
}
return { success: true };
});
```
### Route Handlers
And in Route Handlers:
```tsx
import { NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { enhanceRouteHandler } from '@kit/next/routes';
export const GET = enhanceRouteHandler(
async ({ user }) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('created_by', user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ tasks: data });
},
{ auth: true }
);
```
## Admin Client (Use with Caution)
The admin client bypasses Row Level Security entirely. It uses the service role key and should only be used for:
- Webhook handlers that need to write data without user context
- Admin operations in protected admin routes
- Database migrations or seed scripts
- Background jobs running outside user sessions
```tsx
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
// Example: Webhook handler that needs to update user data
export async function POST(request: Request) {
const payload = await request.json();
// Verify webhook signature first!
if (!verifyWebhookSignature(request)) {
return new Response('Unauthorized', { status: 401 });
}
// Admin client bypasses RLS - use only when necessary
const supabase = getSupabaseServerAdminClient();
const { error } = await supabase
.from('subscriptions')
.update({ status: payload.status })
.eq('stripe_customer_id', payload.customer);
if (error) {
return new Response('Failed to update', { status: 500 });
}
return new Response('OK', { status: 200 });
}
```
### Security Warning
The admin client has unrestricted database access. Before using it:
1. **Verify the request** - Always validate webhook signatures or admin tokens
2. **Validate all input** - Never trust incoming data without validation
3. **Audit access** - Log admin operations for security audits
4. **Minimize scope** - Only query/update what's necessary
```tsx
// WRONG: Using admin client without verification
export async function dangerousEndpoint(request: Request) {
const supabase = getSupabaseServerAdminClient();
const { userId } = await request.json();
// This deletes ANY user - extremely dangerous!
await supabase.from('users').delete().eq('id', userId);
}
// RIGHT: Verify authorization before admin operations
export async function safeEndpoint(request: Request) {
// 1. Verify the request comes from a trusted source
if (!verifyAdminToken(request)) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Validate input
const parsed = AdminActionSchema.safeParse(await request.json());
if (!parsed.success) {
return new Response('Invalid input', { status: 400 });
}
// 3. Now safe to use admin client
const supabase = getSupabaseServerAdminClient();
// ... perform operation
}
```
## TypeScript Integration
All clients are fully typed with your database schema. Generate types from your Supabase project:
```bash
pnpm supabase gen types typescript --project-id your-project-id > packages/supabase/src/database.types.ts
```
Then your queries get full autocomplete and type checking:
```tsx
const supabase = getSupabaseServerClient();
// TypeScript knows the shape of 'tasks' table
const { data } = await supabase
.from('tasks') // autocomplete table names
.select('id, title, completed, created_at') // autocomplete columns
.eq('completed', false); // type-safe filter values
// data is typed as Pick<Task, 'id' | 'title' | 'completed' | 'created_at'>[]
```
## Common Mistakes
### Using Browser Client on Server
```tsx
// WRONG: useSupabase is a React hook, can't use in Server Components
export default async function Page() {
const supabase = useSupabase(); // This will error
}
// RIGHT: Use server client
export default async function Page() {
const supabase = getSupabaseServerClient();
}
```
### Using Admin Client When Not Needed
```tsx
// WRONG: Using admin client for regular user operations
export const getUserTasks = authActionClient
.action(async ({ ctx: { user } }) => {
const supabase = getSupabaseServerAdminClient(); // Unnecessary, bypasses RLS
return supabase.from('tasks').select('*').eq('user_id', user.id);
});
// RIGHT: Use regular server client, RLS handles authorization
export const getUserTasks = authActionClient
.action(async ({ ctx: { user } }) => {
const supabase = getSupabaseServerClient(); // RLS ensures user sees only their data
return supabase.from('tasks').select('*');
});
```
### Creating Multiple Client Instances
```tsx
// WRONG: Creating new client on every call
async function getTasks() {
const supabase = getSupabaseServerClient();
return supabase.from('tasks').select('*');
}
async function getUsers() {
const supabase = getSupabaseServerClient(); // Another instance
return supabase.from('users').select('*');
}
// This is actually fine - the client is lightweight and shares the same
// cookie/auth state. But if you're making multiple queries in one function,
// reuse the instance:
// BETTER: Reuse client within a function
async function loadDashboard() {
const supabase = getSupabaseServerClient();
const [tasks, users] = await Promise.all([
supabase.from('tasks').select('*'),
supabase.from('users').select('*'),
]);
return { tasks, users };
}
```
## Next Steps
Now that you understand the Supabase clients, learn how to use them in different contexts:
- [Server Components](server-components) - Loading data for pages
- [Server Actions](server-actions) - Mutations and form handling
- [React Query](react-query) - Client-side data management