DEV Community

TJ Coding
TJ Coding

Posted on

The Composable Factory Pattern: Your Next.js Boilerplate Killer

We all love the power of Next.js, especially the new App Router. But if you've been building with it, you've probably felt that friction—that sense of "Wait, am I really writing this again?"

I'm talking about the endless try...catch blocks. The repetitive Zod validation in every Server Action. The manual error formatting for NextResponse. This boilerplate code clutters our business logic, makes refactoring a nightmare, and is a prime source of bugs.

What if we could write that code once and then forget about it?

Today, we're going to build a composable factory toolkit. This is a set of "life-saving hacks" that will abstract all that boilerplate away, leaving you with clean, readable, and robust Server Actions and API Routes.

We will build three main pieces:

  1. handlePromise: A Go-lang-inspired utility to force error handling.
  2. ServerAction Factory: A class that builds fully-validated, error-handled Server Actions.
  3. createApiHandler Factory: A function that does the same for your App Router API Routes.

Let's get started.


1. The Foundation: handlePromise

First, let's kill the try...catch block. The problem with try...catch is that it's opt-in. You can forget to write it. We're going to replace it with a pattern that's opt-out.

This simple helper wraps any promise and returns a tuple: [error, data]. If the promise rejects, you get an Error in the first slot. If it resolves, you get your data in the second.

🧰 The Code (lib/utils.ts)

/**
 * Wraps a Promise to return a Go-lang style [error, data] tuple.
 * This forces error handling at the call site.
 *
 * @param promise - The Promise to execute.
 * @returns A tuple: [Error, undefined] if the promise rejects, or [undefined, T] if it resolves.
 */
export async function handlePromise<T>(
  promise: Promise<T>
): Promise<[Error, undefined] | [undefined, T]> {
  try {
    const data = await promise;
    return [undefined, data];
  } catch (error) {
    if (error instanceof Error) {
      return [error, undefined];
    }
    // Handle non-Error throws, just in case
    return [new Error(String(error)), undefined];
  }
}
Enter fullscreen mode Exit fullscreen mode

💡 How It's Used

This utility will be the core of our other factories.

  • Before:

    try {
      const user = await db.user.findFirst(...);
      if (!user) { /* handle no user */ }
      return user;
    } catch (e) {
      console.error(e);
      return { error: "Failed to get user" };
    }
    
  • After:

    const [error, user] = await handlePromise(db.user.findFirst(...));
    
    if (error) {
      console.error(error.message);
      return { error: "Failed to get user" };
    }
    
    if (!user) { /* handle no user */ }
    return user;
    

This is already cleaner, and your linter will yell at you if you have an unused error variable. It forces you to acknowledge failure, which is the first step to robust code.


2. The Workhorse: The ServerAction Factory

Server Actions are fantastic, but a "production-ready" action needs:

  1. Input Validation: (e.g., Zod)
  2. Logic: The actual work.
  3. Error Handling: Catching errors from your logic.
  4. Output Validation: (Optional) Ensuring you return the right shape.
  5. Response Formatting: Returning a consistent { data, error } object for client-side hooks like useActionState.

We're going to build a ServerAction class that handles 1, 3, 4, and 5, so you only have to write #2.

🧰 The Code (lib/server-action.ts)

We'll use Zod for validation. Make sure you've installed it (npm install zod).

'use server';

import { z, ZodError } from 'zod';
import { handlePromise } from './utils';

// --- 1. Define our types ---

// The props our factory will accept
type ServerActionProps<TInput, TOutput> = {
  inputSchema?: z.Schema<TInput>;
  outputSchema?: z.Schema<TOutput>;
  inputType?: 'form' | 'json';
  onError?: (error: Error, input: any) => void;
  metadata?: Record<string, any>;
};

// A standard response format for client hooks
export type ActionResponse<TOutput> = {
  data?: TOutput;
  error?: string;
  validationErrors?: ZodError['issues'];
};

// --- 2. The ServerAction Class ---

export class ServerAction<TInput, TOutput> {
  protected props: ServerActionProps<TInput, TOutput>;

  constructor(props: ServerActionProps<TInput, TOutput>) {
    this.props = {
      inputType: 'form', // Default to FormData
      ...props,
    };
  }

  /**
   * Creates a new ServerAction instance by extending the current configuration.
   * New props will override existing ones.
   */
  public extend<TNewInput = TInput, TNewOutput = TOutput>(
    newProps: Partial<ServerActionProps<TNewInput, TNewOutput>>
  ): ServerAction<TNewInput, TNewOutput> {

    const mergedProps: ServerActionProps<TNewInput, TNewOutput> = {
      ...this.props,
      ...newProps,
      metadata: {
        ...this.props.metadata,
        ...newProps.metadata,
      },
    };
    return new ServerAction<TNewInput, TNewOutput>(mergedProps);
  }

  /**
   * The factory method. It takes your business logic
   * and returns a complete, safe Server Action.
   */
  public action(
    callback: (input: TInput) => Promise<TOutput>
  ): (input: any) => Promise<ActionResponse<TOutput>> {

    return async (input: any): Promise<ActionResponse<TOutput>> => {
      let validatedInput: TInput;

      try {
        // --- 1. Validate Input ---
        if (this.props.inputSchema) {
          const parseTarget = this.props.inputType === 'form' 
            ? Object.fromEntries(input as FormData) 
            : input;
          validatedInput = this.props.inputSchema.parse(parseTarget);
        } else {
          validatedInput = input as TInput;
        }
      } catch (error) {
        if (error instanceof ZodError) {
          return { error: 'Validation failed.', validationErrors: error.issues };
        }
        return { error: 'Invalid input.' };
      }

      // --- 2. Run Business Logic (using our handlePromise!) ---
      const [error, result] = await handlePromise(callback(validatedInput));

      // --- 3. Handle Errors ---
      if (error) {
        this.props.onError?.(error, input); // Run custom error hook
        return { error: error.message || 'An unknown error occurred.' };
      }

      // --- 4. Validate Output (Optional) ---
      if (this.props.outputSchema) {
        const [parseError, validatedResult] = await handlePromise(
          this.props.outputSchema.parseAsync(result)
        );

        if (parseError) {
          this.props.onError?.(parseError, input);
          return { error: 'Invalid server output.' };
        }
        return { data: validatedResult };
      }

      // --- 5. Return Success ---
      return { data: result };
    };
  }
}

// --- 3. A simple helper function ---
export function createServerAction<TInput, TOutput>(
  props: ServerActionProps<TInput, TOutput>
) {
  return new ServerAction<TInput, TOutput>(props);
}
Enter fullscreen mode Exit fullscreen mode

💡 How It's Used: Composable Actions

This is where the extend method becomes magical. We can create a base action and specialize it.

// app/auth/actions.ts
'use server';

import { z } from 'zod';
import { createServerAction } from '@/lib/server-action';
import { lucia, db } from '@/lib/auth';

// 1. Create a BASE action for all auth-related tasks.
// It shares a common error handler and metadata.
const authAction = createServerAction({
  inputType: 'form',
  metadata: { tags: ['auth'] },
  onError: (error) => {
    // Shared logging for all auth actions
    console.error('AUTH_ERROR', error.message);
  },
});

// 2. Define schemas
const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

const registerSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
  password: z.string().min(8),
});

// 3. Create a SPECIALIZED action for logging in
export const login = authAction
  .extend<z.infer<typeof loginSchema>, { userId: string }>({
    inputSchema: loginSchema,
    metadata: { tags: ['auth', 'login'] },
  })
  .action(async (input) => {
    // 'input' is 100% type-safe and validated.
    // No try/catch needed.
    // The 'onError' from authAction is already active.

    // Just write your logic:
    const userId = await lucia.login(input.email, input.password);
    return { userId };
  });

// 4. Create another SPECIALIZED action for registering
export const register = authAction
  .extend<z.infer<typeof registerSchema>, { userId: string }>({
    inputSchema: registerSchema,
  })
  .action(async (input) => {
    // 'input' is { username, email, password }
    const user = await db.user.create(...);
    return { userId: user.id };
  });
Enter fullscreen mode Exit fullscreen mode

Look at that! Your action file contains only schemas and business logic. All the boilerplate is gone.


3. The API Sibling: createApiHandler

We can apply the exact same pattern to App Router API Routes (route.ts). The only difference is that we need to validate params, searchParams, and the body.

🧰 The Code (lib/api-handler.ts)

import { NextRequest, NextResponse } from 'next/server';
import { z, ZodError } from 'zod';
import { handlePromise } from './utils';

// --- 1. Define Types ---
type ApiHandlerProps<TParams, TSearch, TBody> = {
  schemas?: {
    params?: z.Schema<TParams>;
    searchParams?: z.Schema<TSearch>;
    body?: z.Schema<TBody>;
  };
  // The business logic, receives all validated data
  handler: (data: {
    params: TParams;
    searchParams: TSearch;
    body: TBody;
  }) => Promise<any>; // Handler returns raw data
};

type ApiErrorResponse = {
  error: { message: string; issues?: ZodError['issues'] };
};

// --- 2. The Factory Function ---
export function createApiHandler<TParams, TSearch, TBody>({
  schemas,
  handler,
}: ApiHandlerProps<TParams, TSearch, TBody>) {

  return async (
    req: NextRequest,
    context: { params: Record<string, string | string[]> }
  ): Promise<NextResponse<any | ApiErrorResponse>> => {

    let params: TParams, searchParams: TSearch, body: TBody;

    try {
      // --- 1. Validate all inputs ---
      params = schemas?.params
        ? schemas.params.parse(context.params)
        : (context.params as TParams);

      const search = Object.fromEntries(req.nextUrl.searchParams);
      searchParams = schemas?.searchParams
        ? schemas.searchParams.parse(search)
        : (search as TSearch);

      if (req.method !== 'GET' && req.method !== 'DELETE') {
        const json = await req.json();
        body = schemas?.body ? schemas.body.parse(json) : (json as TBody);
      } else {
        body = null as TBody;
      }

    } catch (error) {
      if (error instanceof ZodError) {
        return NextResponse.json<ApiErrorResponse>({
          error: { message: 'Validation failed', issues: error.issues },
        }, { status: 400 });
      }
      return NextResponse.json<ApiErrorResponse>({
        error: { message: 'Invalid request' },
      }, { status: 400 });
    }

    // --- 2. Run Business Logic ---
    const [error, result] = await handlePromise(
      handler({ params, searchParams, body })
    );

    if (error) {
      return NextResponse.json<ApiErrorResponse>({
        error: { message: error.message || 'Server error' },
      }, { status: 500 });
    }

    // --- 3. Return Success ---
    return NextResponse.json(result);
  };
}
Enter fullscreen mode Exit fullscreen mode

💡 How It's Used

Your API route files now become simple configuration objects.

// app/api/posts/[id]/route.ts
import { createApiHandler } from '@/lib/api-handler';
import { z } from 'zod';
import { db } from '@/lib/db';

// 1. Define schemas
const paramsSchema = z.object({ id: z.string().uuid("Invalid post ID") });
const bodySchema = z.object({
  content: z.string().min(10, "Content is too short"),
});

// 2. Create the handler
export const PATCH = createApiHandler({
  schemas: {
    params: paramsSchema,
    body: bodySchema,
  },
  handler: async ({ params, body }) => {
    // 'params' and 'body' are validated and typed!
    // No try/catch needed.

    const updatedPost = await db.post.update({
      where: { id: params.id },
      data: { content: body.content },
    });

    return updatedPost;
  },
});
Enter fullscreen mode Exit fullscreen mode

Again, all boilerplate is gone. Your handler is just the logic.


Conclusion: Take Your Code Back

This pattern is more than just a "hack"—it's a way to enforce consistency and robustness across your entire application. By building a small toolkit of factories, you've:

  • Made your code DRY: Error handling and validation logic live in one place.
  • Made it Robust: Validation isn't optional; it's part of the flow.
  • Made it Readable: Your action and handler files are now pure, clean business logic.

Now, go look at your project. Find those duplicated try...catch blocks and validation snippets. You know what to do.

Top comments (0)