LogoBoring Template

Server Actions

Type-safe server-side functionality with client-side integration using React Query.

Server actions and mutations provide type-safe server-side functionality with client-side integration using React Query. This pattern enables secure server operations with optimized UX through client-side state management.

Project Structure

The application follows a structured approach to organizing server actions and client mutations.

server/
├── actions/           # Server actions
│   ├── auth-actions.ts
│   ├── user-actions.ts
│   └── workspace-actions.ts
└── db/
    └── mutations/     # Client-side mutations
        ├── use-user-api.ts
        ├── use-workspace-api.ts
        └── use-member-api.ts

Server Actions

Server actions are server-only functions that handle operations like database mutations, authentication, and API integrations. They are marked with the "use server" directive to ensure they only execute on the server.

Server-Side Implementation

Server actions are located in server/actions/ and handle server-side operations with built-in error handling and rate limiting.

Example: User Action

Server-side implementation of user creation:

server/actions/user-actions.ts
"use server";
 
export const createUserAction = async ({
  values,
  isFromInvitation,
}: {
  values: z.infer<typeof userSchema>;
  isFromInvitation?: boolean;
}) => {
  try {
    // 1. Validate input
    const validateValues = userSchema.safeParse(values);
    if (!validateValues.success) {
      throw new ValidationError(validateValues.error.message);
    }
 
    // 2. Check authentication
    const { user } = await getCurrentUser();
    if (!user) {
      throw new AuthenticationError();
    }
 
    // 3. Check rate limit
    const identifier = `ratelimit:create-user:${user.id}`;
    const { success } = await ratelimit.limit(identifier);
    if (!success) {
      throw new RateLimitError();
    }
 
    // 4. Perform database operations
    return await dbClient.transaction(async (tx) => {
      // ... implementation
    });
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    throw new DatabaseError("Failed to create user");
  }
};

Error Handling

Server actions implement standardized error handling using custom error types:

server/lib/errors.ts
export class ApiError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ApiError";
  }
}
 
export class ValidationError extends ApiError {
  constructor(message = "Validation failed") {
    super(message);
    this.name = "ValidationError";
  }
}
 
export class AuthenticationError extends ApiError {
  constructor(message = "Authentication required") {
    super(message);
    this.name = "AuthenticationError";
  }
}
 
export class RateLimitError extends ApiError {
  constructor(message = "Rate limit exceeded") {
    super(message);
    this.name = "RateLimitError";
  }
}
 
export class DatabaseError extends ApiError {
  constructor(message = "Database operation failed") {
    super(message);
    this.name = "DatabaseError";
  }
}

Client Mutations

Client mutations are React hooks that wrap server actions with React Query for state management, providing optimistic updates, loading states, and automatic cache invalidation.

Client-Side Integration

Client mutations are React hooks located in server/db/mutations/ that wrap server actions with React Query for state management.

Example: User Mutation Hook

Client-side mutation hook for user creation:

server/db/mutations/use-user-api.ts
export const useCreateUser = ({
  isFromInvitation,
}: {
  isFromInvitation: boolean;
}) => {
  const router = useRouter();
 
  const { mutate, isPending } = useMutation({
    mutationFn: createUserAction,
    onSuccess: (data) => {
      toast.success(data.message);
      router.refresh();
      router.push(
        data.hasWorkspace
          ? createRoute("onboarding-collaborate").href
          : createRoute("onboarding-workspace").href
      );
    },
    onError: (error: any) => {
      toast.error(error.message || GLOBAL_ERROR_MESSAGE);
    },
  });
 
  return { isPending, server_createUser: mutate };
};

Standardized Naming Conventions

Client mutations follow a naming convention to clearly distinguish between client-side and server-side functions:

  1. Hooks are named with use prefix (e.g., useCreateUser)
  2. Server action functions exposed to components are prefixed with server_ (e.g., server_createUser)
  3. Server-side actions end with Action suffix (e.g., createUserAction)

Usage in Components

Component implementation uses the client mutation hooks to interact with server actions while providing proper loading states and error handling.

Example: Using Mutations

Using mutation hooks in React components:

components/users/create-user-form.tsx
function CreateUserForm() {
  const { server_createUser, isPending } = useCreateUser({
    isFromInvitation: false,
  });
 
  const onSubmit = (values: FormData) => {
    server_createUser(values);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </Button>
    </form>
  );
}

Using with React Hook Form

Integration with React Hook Form for form handling:

Example: React Hook Form integration
function CreateUserForm() {
  const { server_createUser, isPending } = useCreateUser({
    isFromInvitation: false,
  });
 
  const form = useForm<z.infer<typeof userSchema>>({
    resolver: zodResolver(userSchema),
    defaultValues: {
      name: "",
      email: "",
    },
  });
 
  const onSubmit = form.handleSubmit((values) => {
    server_createUser({ values });
  });
 
  return (
    <Form {...form}>
      <form onSubmit={onSubmit} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={isPending}>
          {isPending ? "Creating..." : "Create User"}
        </Button>
      </form>
    </Form>
  );
}

Key Features

Rate Limiting

⏱️ Built-in rate limiting using Upstash Redis

Error Handling

🛡️ Standardized error handling with custom error types

Type Safety

🔒 Full TypeScript support with Zod validation

State Management

🔄 Automatic cache invalidation with React Query

Common Server Actions

The application includes several common server actions for authentication, user management, and workspace operations.

Authentication Actions

server/actions/auth-actions.ts
"use server";
 
export const signInAction = async (values: z.infer<typeof loginSchema>) => {
  try {
    // Validate input
    const validatedValues = loginSchema.safeParse(values);
    if (!validatedValues.success) {
      throw new ValidationError(validatedValues.error.message);
    }
 
    // Apply rate limiting
    const identifier = `ratelimit:sign-in:${values.email}`;
    const { success } = await ratelimit.limit(identifier);
    if (!success) {
      throw new RateLimitError();
    }
 
    // Sign in using Auth.js
    const signInResult = await signIn("credentials", {
      email: values.email,
      password: values.password,
      redirect: false,
    });
 
    if (signInResult?.error) {
      throw new AuthenticationError("Invalid credentials");
    }
 
    return { message: "Signed in successfully" };
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    throw new DatabaseError("Failed to sign in");
  }
};

Workspace Actions

server/actions/workspace-actions.ts
"use server";
 
export const createWorkspaceAction = async ({
  values,
  isInitial,
}: {
  values: z.infer<typeof workspaceSchema>;
  isInitial: boolean;
}) => {
  try {
    // Validate input
    const validatedValues = workspaceSchema.safeParse(values);
    if (!validatedValues.success) {
      throw new ValidationError(validatedValues.error.message);
    }
 
    // Check authentication
    const { user } = await getCurrentUser();
    if (!user) {
      throw new AuthenticationError();
    }
 
    // Apply rate limiting
    const identifier = `ratelimit:create-workspace:${user.id}`;
    const { success } = await ratelimit.limit(identifier);
    if (!success) {
      throw new RateLimitError();
    }
 
    // Create workspace in database
    return await dbClient.transaction(async (tx) => {
      // ... implementation
    });
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    throw new DatabaseError("Failed to create workspace");
  }
};

Best Practices

Server Actions

  • Use 'use server' directive
  • Implement proper validation
  • Handle all error cases
  • Use transactions for related operations

Client Mutations

  • Prefix mutation functions with 'server_'
  • Handle loading states
  • Provide meaningful error messages
  • Update UI optimistically when possible

Error Handling

  • Use custom error types
  • Include specific error messages
  • Log errors for debugging
  • Return consistent error formats

TypeScript

  • Define input and output types
  • Use Zod for validation
  • Export type definitions
  • Use generics for reusable actions

All server actions should be properly rate-limited and validated to prevent abuse. Always handle errors appropriately and provide meaningful feedback to users. Learn more about rate limiting and error handling.

Advanced Usage

Optimistic Updates

Implement optimistic updates for better user experience:

Example: Optimistic updates
export const useUpdateItem = (itemId: string) => {
  const queryClient = useQueryClient();
 
  const { mutate, isPending } = useMutation({
    mutationFn: updateItemAction,
    // Update cache optimistically before server response
    onMutate: async (newItem) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ["items", itemId] });
 
      // Snapshot previous value
      const previousItem = queryClient.getQueryData(["items", itemId]);
 
      // Optimistically update cache
      queryClient.setQueryData(["items", itemId], newItem);
 
      // Return context with previous value
      return { previousItem };
    },
    // If error occurs, revert to previous value
    onError: (err, newItem, context) => {
      queryClient.setQueryData(["items", itemId], context?.previousItem);
      toast.error("Failed to update item");
    },
    // After success or error, invalidate query
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["items", itemId] });
    },
  });
 
  return { isPending, server_updateItem: mutate };
};

Batch Operations

Handle multiple operations in a single server action:

Example: Batch operations
"use server";
 
export const batchUpdateItemsAction = async ({
  workspaceId,
  items,
}: {
  workspaceId: string;
  items: { id: string; values: ItemUpdateValues }[];
}) => {
  try {
    // Check authentication and permissions
    const { user } = await getCurrentUser();
    if (!user) {
      throw new AuthenticationError();
    }
 
    const hasPermission = await checkPermission(
      user.id,
      workspaceId,
      "update:items"
    );
 
    if (!hasPermission) {
      throw new AuthorizationError("You don't have permission to update items");
    }
 
    // Apply rate limiting
    const identifier = `ratelimit:batch-update-items:${user.id}`;
    const { success } = await ratelimit.limit(identifier);
    if (!success) {
      throw new RateLimitError();
    }
 
    // Perform batch update in transaction
    return await dbClient.transaction(async (tx) => {
      const results = [];
 
      for (const item of items) {
        // Update each item
        const result = await tx
          .update(itemsTable)
          .set(item.values)
          .where(eq(itemsTable.id, item.id))
          .returning();
 
        results.push(result[0]);
      }
 
      return {
        message: `Updated ${results.length} items successfully`,
        items: results,
      };
    });
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    throw new DatabaseError("Failed to update items");
  }
};

Additional Resources

React Query Documentation

Learn more about React Query state management on the TanStack Query documentation.

Next.js Server Actions

Read about server actions in the Next.js documentation.

Zod Validation

Explore Zod documentation for type validation.

Rate Limiting

See our Rate Limiting documentation for details on implementation.