zsa

repository·main·Indexed 21 days ago

https://github.com/idopesok/zsa

A library for building typesafe server actions in Next.js with validated inputs/outputs using Zod, middleware-style procedures, and lifecycle callbacks. It includes an ecosystem of integrations: zsa-react for React, zsa-react-query for TanStack Query, and zsa-openapi for exposing actions as RESTful endpoints. Note: zsa does not support Next.js 15.

Tokens
37.5K
Snippets
110
Records
134
Agent score
76%

What's inside zsa

  1. Overview of zsa features

    main

    zsa is a library designed for building typesafe server actions in Next.js. It provides several key capabilities for managing server-side logic and client-side consumption:

    • Type Safety: Full TypeScript support for inputs and outputs.
    • Validation: Uses Zod schemas to validate both inputs and outputs.
    • Procedures: Middleware-like functionality to add context (e.g., authentication) and authorization to server actions.
    • Lifecycle Callbacks: Ability to run logic based on the server action lifecycle.
    • Client Integration: Built-in loading states, error handling, and seamless React Query integration for querying actions in client components.
    • Input Support: Native support for FormData as an input type.
    • Resilience: Includes retry functionality and timeouts for server actions.
  2. How callbacks work in ZSA

    main

    Callbacks allow you to execute side effects (like logging or error handling) based on the lifecycle of a server action. You can define them on individual actions or at the procedure level to share them across multiple actions.

    Lifecycle Order

    When an action is invoked, the callbacks execute in this specific sequence:

    1. onStart runs.
    2. onInputParseError runs if input parsing fails (execution stops here).
    3. The main action handler runs.
    4. onSuccess runs (if handler succeeds) OR onError runs (if handler throws).
    5. onComplete runs under all exit conditions.
    6. The action's response is returned.

    Procedure vs. Action Callbacks

    • Procedure Callbacks: Defined on a createServerActionProcedure(). They run for every action that uses that procedure.
    • Execution Order: Procedure callbacks execute before their corresponding action callbacks. For example, if both a procedure and an action have an onComplete callback, the procedure's onComplete runs first.
    const exampleAction = createServerAction()
      .input(z.object({message: z.string()}))
      .onStart(async () => { /* ... */ })
      .onSuccess(async () => { /* ... */ })
      .onComplete(async () => { /* ... */ })
      .onError(async () => { /* ... */ })
      .onInputParseError(async () => { /* ... */ })
      .handler(async ({input}) => { /* ... */ })
  3. Perform optimistic updates with useServerAction

    main

    You can use setOptimistic to update the data value immediately in the UI before the server responds. This provides a faster perceived experience. If the server action eventually fails, ZSA automatically rolls back the data to its previous state.

    setOptimistic accepts either the new data directly or a functional updater.

    const { data, setOptimistic } = useServerAction(myAction);
    
    const handleUpdate = async () => {
      // Update UI immediately
      setOptimistic((prev) => ({ ...prev, name: 'New Name' }));
      
      // Trigger actual server action
      await execute({ name: 'New Name' });
    };
  4. Access the request object for authentication

    main

    When using createOpenApiServerActionRouter or createRouteHandlersForAction, the NextRequest object is automatically injected into your server action handler. You can use this to inspect headers (e.g., for API keys) or cookies to perform authentication.

    export const getReplyWithHeaders = createServerAction()
        .input(
            z.object({ postId: z.string(), replyId: z.string(), message: z.string() })
        )
        .handler(async ({ input, request }) => {
            if (request) {
                const apiKey = request.headers.get("authorization")?.split(" ")[1]
                if (!apiKey || apiKey !== "123") {
                    throw new Error("NOT_AUTHORIZED")
                }
                return { user: { id: 123, name: "test" } }
            }
            // ... fallback to cookie auth
        })
  5. Compare direct action calls vs useServerAction hook

    main

    Directly calling server actions is a low-level approach with specific trade-offs compared to the useServerAction hook:

    FeatureDirect CalluseServerAction Hook
    Loading StatesManual management requiredBuilt-in (isPending, etc.)
    Optimistic UpdatesManual implementation requiredBuilt-in support
    Error HandlingManual via err tupleIntegrated into hook state
    ComplexitySimpler/Lower levelMore declarative/Feature-rich

    Use Direct Calls for simple execution where you want total control over state. Use useServerAction for standard UI patterns requiring loading indicators or optimistic UI.

  6. Use typedData for error shaping

    main

    When defining error shapes, you can use the typedData object provided to the callback to access input information. This is useful when TypeScript cannot yet infer the full input/output schemas.

    typedData contains:

    • inputRaw: The raw input data.
    • inputParsed: The parsed input data.
    • inputParseErrors: Validation errors for the input.
    • outputParseErrors: Validation errors for the output.

    Note: You must return typedData properties as values within an object; you cannot return a typedData property directly as the error object.

    const shapeErrorAction = createServerAction()
      .input(z.object({ number: z.number().refine((n) => n > 0) }))
      .experimental_shapeError(({ err, typedData }) => {
        return {
          inputRaw: typedData.inputRaw,
          inputParsed: typedData.inputParsed,
          inputParseErrors: typedData.inputParseErrors,
          outputParseErrors: typedData.outputParseErrors
        }
      })
      .handler(async ({ input }) => {
        return input.number
      })
  7. How to chain procedures for layered authorization

    main

    You can chain procedures together to create complex authorization layers. By passing an existing procedure as an argument to createServerActionProcedure(previousProcedure), the new procedure will run after the previous one.

    Each procedure in the chain can access the ctx returned by the preceding procedure, allowing you to pass data forward (e.g., passing a user object from an authedProcedure to an isAdminProcedure). The final server action will only execute if every procedure in the chain succeeds without throwing an error.

    // 1. Base procedure
    const authedProcedure = createServerActionProcedure().handler(async () => { /* returns user */ });
    
    // 2. Chained procedure (runs after authedProcedure)
    const isAdminProcedure = createServerActionProcedure(authedProcedure)
      .handler(async ({ ctx }) => {
        const role = await getUserRole(ctx.user.id);
        if (role !== "admin") throw new Error("User is not an admin");
        
        return { ...ctx.user, role }; // Passes updated context forward
      });
    
    // 3. Final Action
    const deleteUser = isAdminProcedure
      .createServerAction()
      .input(z.object({ userIdToDelete: z.string() }))
      .handler(async ({ input, ctx }) => {
        // ctx contains data from BOTH authedProcedure and isAdminProcedure
        const { user } = ctx;
        // ... logic
      });
  8. What are Procedures and how to create a basic one

    main

    Procedures allow you to add additional context to a set of server actions, such as a userId. They are used to enforce conditions (like authentication or permissions) before an action's handler is executed.

    To create a basic procedure, use createServerActionProcedure(). You define its logic in a .handler() method. This handler can return data that will be injected into the ctx (context) of any server action that chains off this procedure.

    When you chain .createServerAction() onto a procedure, the procedure's handler runs first. If the procedure's handler throws an error, the server action's handler will not execute.

    import { createServerActionProcedure } from "zsa"
    
    const authedProcedure = createServerActionProcedure()
      .handler(async () => {
        try {
          const { email, id } = await getUser();
          return { user: { email, id } }
        } catch {
          throw new Error("User not authenticated")
        }
      })
    
    export const updateEmail = authedProcedure
      .createServerAction()
      .input(z.object({ newEmail: z.string() }))
      .handler(async ({ input, ctx }) => {
        const { user } = ctx;
        // ... logic using ctx.user
        return input.newEmail;
      });
  9. Chain shaped errors in procedures

    main

    When using createServerActionProcedure, shaped errors can be chained. A subsequent procedure in the chain has access to a ctx object containing the error shape defined by the previous procedure. This allows you to incrementally build a complex error object.

    const procedureA = createServerActionProcedure()
      .experimental_shapeError(({ err, typedData }) => {
        return { isError: true }
      })
      .handler(() => {})
    
    const procedureB = createServerActionProcedure(procedureA)
      .experimental_shapeError(({ err, typedData, ctx }) => {
        return { ...ctx, addingOn: true }
      })
      .handler(() => {})
    
    const action = procedureB.createServerAction()
      .experimental_shapeError(({ err, typedData, ctx }) => {
        return { ...ctx, addingOnFromAction: true }
      })
      .handler(() => {})
    
    const [data, err] = await action()
    type ERROR = typeof err
    //   ^? { isError: true; addingOn: true; addingOnFromAction: true } | null
  10. Understand timeout precedence

    main

    When a timeout is defined at both the Procedure level and the Action level, the Action's timeout takes precedence and overwrites the Procedure's setting.

    Example Scenario:

    • protectedProcedure has a timeout of 1000ms.
    • exampleAction (built from protectedProcedure) has a timeout of 500ms.
    • Result: The effective timeout for exampleAction is 500ms.
  11. How typed errors (TZSAError) work with validation

    main

    When using TZSAError (a generic version of ZSAError that takes a Zod schema as a type parameter), errors with the code INPUT_PARSE_ERROR provide enhanced validation details.

    If the server action's input fails validation, the error object will include properties inferred from the Zod schema:

    • fieldErrors: An object mapping field names to arrays of error messages.
    • formErrors: An array of error messages applicable to the entire form.
    • formattedErrors: A formatted version of the validation errors.