Files
myeasycms-v2/packages/next/AGENTS.md
Giancarlo Buomprisco 9712e2354b MCP/Rules Improvements + MCP Prompts (#357)
- Use ESM for building the MCP Server
- Added own Postgres dependency to MCP Server for querying tables and other entities in MCP
- Vastly improved AI Agent rules
- Added MCP Prompts for reviewing code and planning features
- Minor refactoring
2025-09-19 22:57:35 +08:00

11 KiB

Next.js Utilities Instructions

This file contains instructions for working with Next.js utilities including server actions and route handlers.

Server Actions Implementation

Always use enhanceAction from @packages/next/src/actions/index.ts:

'use server';

import { enhanceAction } from '@kit/next/actions';
import { z } from 'zod';

// Define your schema
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'),
});

export const createNoteAction = enhanceAction(
  async function (data, user) {
    // data is automatically validated against the schema
    // user is automatically authenticated if auth: true
    
    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;
    }
    
    return { success: true, note };
  },
  {
    auth: true, // Require authentication
    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

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:

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

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

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:

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

'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

'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;
  }
};

Security Best Practices

Input Validation

Always use Zod schemas for input validation:

// Define strict schemas
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).max(120),
});

// Server action with validation
export const updateUserAction = enhanceAction(
  async function (data, user) {
    // data is guaranteed to match the schema
    // Additional business logic validation can go here
    
    if (data.email !== user.email) {
      // Check if email change is allowed
      const canChangeEmail = await checkEmailChangePermission(user);
      if (!canChangeEmail) {
        throw new Error('Email change not allowed');
      }
    }
    
    // Update user
    return await updateUser(user.id, data);
  },
  {
    auth: true,
    schema: UpdateUserSchema,
  },
);

Authorization Checks

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,
  },
);