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:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
209
docs/data-fetching/captcha-protection.mdoc
Normal file
209
docs/data-fetching/captcha-protection.mdoc
Normal 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 });
|
||||
```
|
||||
32
docs/data-fetching/csrf-protection.mdoc
Normal file
32
docs/data-fetching/csrf-protection.mdoc
Normal 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.
|
||||
713
docs/data-fetching/react-query.mdoc
Normal file
713
docs/data-fetching/react-query.mdoc
Normal 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
|
||||
567
docs/data-fetching/route-handlers.mdoc
Normal file
567
docs/data-fetching/route-handlers.mdoc
Normal 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
|
||||
816
docs/data-fetching/server-actions.mdoc
Normal file
816
docs/data-fetching/server-actions.mdoc
Normal 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
|
||||
487
docs/data-fetching/server-components.mdoc
Normal file
487
docs/data-fetching/server-components.mdoc
Normal 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
|
||||
354
docs/data-fetching/supabase-clients.mdoc
Normal file
354
docs/data-fetching/supabase-clients.mdoc
Normal 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
|
||||
Reference in New Issue
Block a user