1. Added Claude Code sub-agents 2. Added PRD tool to MCP Server 3. Added MCP Server UI to Dev Tools 4. Improved MCP Server Database Tool 5. Updated dependencies
478 lines
11 KiB
Markdown
478 lines
11 KiB
Markdown
# Next.js Utilities Instructions
|
|
|
|
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
|
|
|
## Guidelines
|
|
|
|
- Don't use Server Actions for data-fetching, use for mutations only
|
|
- Best Practice: Keep actions light, move business logic to ad-hoc services
|
|
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
|
|
- Do not expose sensitive data
|
|
- Log async operations
|
|
- Validate body with Zod
|
|
- Use 'use server' at the top of the file. No need for 'server only';
|
|
|
|
## Server Actions Implementation
|
|
|
|
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
|
|
|
|
Define a schema:
|
|
|
|
```tsx
|
|
import { z } from 'zod';
|
|
|
|
// Define your schema in its own file
|
|
export const CreateNoteSchema = z.object({
|
|
title: z.string().min(1, 'Title is required'),
|
|
content: z.string().min(1, 'Content is required'),
|
|
accountId: z.string().uuid('Invalid account ID'),
|
|
});
|
|
```
|
|
|
|
Then we define a service for crossing the network boundary:
|
|
|
|
```tsx
|
|
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
|
|
import * as z from 'zod';
|
|
|
|
export function createNotesService() {
|
|
return new NotesService();
|
|
}
|
|
|
|
class NotesService {
|
|
createNote(data: z.infer<CreateNoteSchema>) {
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { data: note, error } = await client
|
|
.from('notes')
|
|
.insert({
|
|
title: data.title,
|
|
content: data.content,
|
|
account_id: data.accountId,
|
|
user_id: user.id,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Finally, we use Server Actions for exposing POST handlers:
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { enhanceAction } from '@kit/next/actions';
|
|
import { createNotesService } from '../notes.service.ts';
|
|
|
|
export const createNoteAction = enhanceAction(
|
|
async function (data, user) {
|
|
// data is automatically validated against the schema
|
|
// user is automatically authenticated if auth: true
|
|
|
|
const service = createNotesService();
|
|
const logger = await getLogger();
|
|
|
|
logger.info({
|
|
userId: user.id,
|
|
}, `Creating note...`);
|
|
|
|
const { data: note, error } = await service.createNote(data);
|
|
|
|
if (error) {
|
|
logger.error({
|
|
error: error.message
|
|
}, `Error creating note`);
|
|
|
|
throw error;
|
|
}
|
|
|
|
logger.info({
|
|
noteId: note.id
|
|
}, `Note successfully created`);
|
|
|
|
return {
|
|
success: true,
|
|
note
|
|
};
|
|
},
|
|
{
|
|
auth: true, // Require authentication (true by default, can omit)
|
|
schema: CreateNoteSchema, // Validate input with Zod
|
|
},
|
|
);
|
|
```
|
|
|
|
### Server Action Examples
|
|
|
|
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
|
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
|
|
|
### Server Action Options
|
|
|
|
```typescript
|
|
export const myAction = enhanceAction(
|
|
async function (data, user) {
|
|
// data: validated input data
|
|
// user: authenticated user (if auth: true)
|
|
|
|
return { success: true };
|
|
},
|
|
{
|
|
auth: true, // Require authentication (default: false)
|
|
schema: MySchema, // Zod schema for validation (optional)
|
|
// Additional options available
|
|
},
|
|
);
|
|
```
|
|
|
|
## Route Handlers (API Routes)
|
|
|
|
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
|
|
|
|
### Guidelines
|
|
|
|
- Use when data must be exposed to externally
|
|
- Use for receiving requests from external clients (such as webhooks)
|
|
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
|
|
|
|
### Usage
|
|
|
|
```typescript
|
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
|
import { NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
|
|
// Define your schema
|
|
const CreateItemSchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
});
|
|
|
|
export const POST = enhanceRouteHandler(
|
|
async function ({ body, user, request }) {
|
|
// body is validated against schema
|
|
// user is available if auth: true
|
|
// request is the original NextRequest
|
|
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { data, error } = await client
|
|
.from('items')
|
|
.insert({
|
|
name: body.name,
|
|
description: body.description,
|
|
user_id: user.id,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create item' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ success: true, data });
|
|
},
|
|
{
|
|
auth: true, // Require authentication
|
|
schema: CreateItemSchema, // Validate request body
|
|
},
|
|
);
|
|
|
|
export const GET = enhanceRouteHandler(
|
|
async function ({ user, request }) {
|
|
const url = new URL(request.url);
|
|
const limit = url.searchParams.get('limit') || '10';
|
|
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { data, error } = await client
|
|
.from('items')
|
|
.select('*')
|
|
.eq('user_id', user.id)
|
|
.limit(parseInt(limit));
|
|
|
|
if (error) {
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch items' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ data });
|
|
},
|
|
{
|
|
auth: true,
|
|
// No schema needed for GET requests
|
|
},
|
|
);
|
|
```
|
|
|
|
### Route Handler Options
|
|
|
|
```typescript
|
|
export const POST = enhanceRouteHandler(
|
|
async function ({ body, user, request }) {
|
|
// Handler function
|
|
return NextResponse.json({ success: true });
|
|
},
|
|
{
|
|
auth: true, // Require authentication (default: false)
|
|
schema: MySchema, // Zod schema for body validation (optional)
|
|
// Additional options available
|
|
},
|
|
);
|
|
```
|
|
|
|
## Revalidation
|
|
|
|
- Use `revalidatePath` for revalidating data after a migration.
|
|
- Avoid calling `router.refresh()` or `router.push()` following a Server Action. Use `revalidatePath` and `redirect` from the server action instead.
|
|
|
|
## Error Handling Patterns
|
|
|
|
### Server Actions with Error Handling
|
|
|
|
```typescript
|
|
export const createNoteAction = enhanceAction(
|
|
async function (data, user) {
|
|
const logger = await getLogger();
|
|
const ctx = { name: 'create-note', userId: user.id };
|
|
|
|
try {
|
|
logger.info(ctx, 'Creating note');
|
|
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { data: note, error } = await client
|
|
.from('notes')
|
|
.insert({
|
|
title: data.title,
|
|
content: data.content,
|
|
user_id: user.id,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
logger.error({ ...ctx, error }, 'Failed to create note');
|
|
throw error;
|
|
}
|
|
|
|
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
|
|
|
|
return { success: true, note };
|
|
} catch (error) {
|
|
if (!isRedirectError(error)) {
|
|
logger.error({ ...ctx, error }, 'Create note action failed');
|
|
throw error;
|
|
}
|
|
}
|
|
},
|
|
{
|
|
auth: true,
|
|
schema: CreateNoteSchema,
|
|
},
|
|
);
|
|
```
|
|
|
|
|
|
### Server Action Redirects - Client Handling
|
|
|
|
When server actions call `redirect()`, it throws a special error that should NOT be treated as a failure:
|
|
|
|
```typescript
|
|
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
|
|
|
async function handleSubmit(formData: FormData) {
|
|
try {
|
|
await myServerAction(formData);
|
|
} catch (error) {
|
|
// Don't treat redirects as errors
|
|
if (!isRedirectError(error)) {
|
|
// Handle actual errors
|
|
toast.error('Something went wrong');
|
|
}
|
|
}
|
|
}
|
|
|
|
### Route Handler with Error Handling
|
|
|
|
```typescript
|
|
export const POST = enhanceRouteHandler(
|
|
async function ({ body, user }) {
|
|
const logger = await getLogger();
|
|
const ctx = { name: 'api-create-item', userId: user.id };
|
|
|
|
try {
|
|
logger.info(ctx, 'Processing API request');
|
|
|
|
// Process request
|
|
const result = await processRequest(body, user);
|
|
|
|
logger.info({ ...ctx, result }, 'API request successful');
|
|
|
|
return NextResponse.json({ success: true, data: result });
|
|
} catch (error) {
|
|
logger.error({ ...ctx, error }, 'API request failed');
|
|
|
|
if (error.message.includes('validation')) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid input data' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
},
|
|
{
|
|
auth: true,
|
|
schema: CreateItemSchema,
|
|
},
|
|
);
|
|
```
|
|
|
|
## Client-Side Integration
|
|
|
|
### Using Server Actions in Components
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useTransition } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { toast } from '@kit/ui/sonner';
|
|
import { Button } from '@kit/ui/button';
|
|
|
|
import { createNoteAction } from './actions';
|
|
import { CreateNoteSchema } from './schemas';
|
|
|
|
function CreateNoteForm() {
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(CreateNoteSchema),
|
|
defaultValues: {
|
|
title: '',
|
|
content: '',
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data) => {
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await createNoteAction(data);
|
|
|
|
if (result.success) {
|
|
toast.success('Note created successfully!');
|
|
form.reset();
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to create note');
|
|
console.error('Create note error:', error);
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
{/* Form fields */}
|
|
|
|
<Button type="submit" disabled={isPending}>
|
|
{isPending ? 'Creating...' : 'Create Note'}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
NB: When using `redirect`, we must handle it using `isRedirectError` otherwise we display an error after the server action succeeds
|
|
|
|
### Using Route Handlers with Fetch
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
async function createItem(data: CreateItemInput) {
|
|
const response = await fetch('/api/items', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'Failed to create item');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Usage in component
|
|
const handleCreateItem = async (data) => {
|
|
try {
|
|
const result = await createItem(data);
|
|
toast.success('Item created successfully!');
|
|
return result;
|
|
} catch (error) {
|
|
toast.error('Failed to create item');
|
|
throw error;
|
|
}
|
|
};
|
|
```
|
|
|
|
### Authorization Checks
|
|
|
|
```typescript
|
|
export const deleteAccountAction = enhanceAction(
|
|
async function (data, user) {
|
|
const client = getSupabaseServerClient();
|
|
|
|
// Verify user owns the account
|
|
const { data: account, error } = await client
|
|
.from('accounts')
|
|
.select('id, primary_owner_user_id')
|
|
.eq('id', data.accountId)
|
|
.single();
|
|
|
|
if (error || !account) {
|
|
throw new Error('Account not found');
|
|
}
|
|
|
|
if (account.primary_owner_user_id !== user.id) {
|
|
throw new Error('Only account owners can delete accounts');
|
|
}
|
|
|
|
// Additional checks
|
|
const hasActiveSubscription = await client
|
|
.rpc('has_active_subscription', { account_id: data.accountId });
|
|
|
|
if (hasActiveSubscription) {
|
|
throw new Error('Cannot delete account with active subscription');
|
|
}
|
|
|
|
// Proceed with deletion
|
|
await deleteAccount(data.accountId);
|
|
|
|
return { success: true };
|
|
},
|
|
{
|
|
auth: true,
|
|
schema: DeleteAccountSchema,
|
|
},
|
|
);
|
|
``` |