How to Structure Payload CMS Collections for Long-Term Maintainability

Use feature-based colocation: hooks, business logic, and types together per collection

·Matija Žiberna·
How to Structure Payload CMS Collections for Long-Term Maintainability

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

How to Structure Payload CMS Collections for Long-Term Maintainability

Last week, I was working on a Payload CMS project managing inventory and orders for an agricultural business. The system had grown to handle complex workflows - tracking delivery windows, managing perishable product batches, calculating inventory adjustments across multiple warehouses. Everything worked, but the codebase had become a maze.

The Orders collection alone was over 1,000 lines in a single file. Business logic for inventory calculations lived in src/lib/orders/inventory.ts, line item normalization was in src/lib/orders/lineItems.ts, and validation helpers were scattered across multiple utility folders. When I needed to understand how order processing worked, I found myself jumping between seven different files spread across four directories. It was exhausting.

After refactoring five major collections using a colocation strategy borrowed from modern React practices, the difference was striking. Everything related to Orders now lives in one folder. Finding code takes seconds instead of minutes. New developers can understand the flow by looking at a single directory tree. This guide walks you through the exact structure I landed on and why it works.

The Problem with Traditional CMS Organization

Most Payload projects start simple. You create a collection, add some fields, maybe write a hook or two. The collection file grows to a hundred lines, then two hundred. Eventually you extract some business logic into a utilities folder because "it's cleaner." Hooks get complex enough that you move them to a dedicated hooks directory. Field validators get their own files.

Before you know it, understanding a single collection requires opening files in four different directories. The Orders collection config imports from @/lib/orders/inventory, @/lib/orders/lineItems, @/lib/orders/totals, and @/hooks/orders/validateInventory. When a bug appears in order processing, you're hunting through the entire project structure trying to trace the flow.

The root cause is organizing by technical concern rather than by feature. We put all hooks together, all utilities together, all validation together. It feels organized at first - everything has its place. But it creates artificial distance between code that naturally works as a unit.

The Colocation Principle

The solution comes from a principle that's become standard in modern React development: keep code that changes together close together. If your Orders collection has hooks, business logic, validators, and type definitions, those pieces form a cohesive unit. They should live in the same folder.

Here's what the refactored Orders collection looks like:

src/collections/Orders/
├── index.ts                    # Collection configuration
├── hooks/                      # Lifecycle hooks
│   ├── beforeValidate.ts
│   ├── beforeChange.ts
│   └── afterChange.ts
└── lib/                        # Business logic
    ├── inventory.ts            # Inventory calculations
    ├── lineItems.ts            # Line item normalization
    ├── totals.ts               # Order totals
    ├── types.ts                # TypeScript types
    └── relationships.ts        # Helper functions

Everything about Orders lives under src/collections/Orders/. When you need to understand order processing, you open one folder. When you need to modify inventory logic, you know exactly where to look. The folder structure tells the story of what the collection does.

Building the Structure: The Index File

The collection configuration file becomes cleaner when hooks and utilities are extracted. Instead of 1,000 lines of mixed concerns, the index file focuses purely on defining the collection's shape.

// File: src/collections/Orders/index.ts
import { superAdminOrTenantAdminAccess } from "@/access/superAdminOrTenantAdmin";
import {
  FINANCIAL_STATUSES,
  LINE_ITEM_FULFILLMENT_STATUSES,
  ORDER_FULFILLMENT_STATUSES,
} from "./lib/types";
import type { CollectionConfig } from "payload";

// Import hooks from dedicated files
import { beforeValidate } from "./hooks/beforeValidate";
import { beforeChange } from "./hooks/beforeChange";
import { afterChange } from "./hooks/afterChange";

const LINE_ITEM_FULFILLMENT_OPTIONS = LINE_ITEM_FULFILLMENT_STATUSES.map((value) => ({
  label: { sl: value, en: value },
  value,
}));

export const Orders: CollectionConfig = {
  slug: "orders",
  labels: {
    singular: { sl: "Naročilo", en: "Order" },
    plural: { sl: "Naročila", en: "Orders" },
  },

  hooks: {
    beforeValidate: [beforeValidate],
    beforeChange: [beforeChange],
    afterChange: [afterChange],
  },

  fields: [
    // Field definitions...
  ],
};

export default Orders;

Notice the imports use relative paths: ./hooks/beforeValidate and ./lib/types. This makes it immediately clear the code is local to this collection. If you ever need to rename or move the collection, all its dependencies move with it.

Organizing Hooks: One File Per Lifecycle Event

Each Payload lifecycle event gets its own file in the hooks folder. This separation serves several purposes. First, it keeps each hook focused on a single responsibility. Second, it makes the codebase easier to navigate - you can find the beforeChange logic without scrolling through hundreds of lines. Third, it improves testability since each hook can be tested in isolation.

The beforeValidate hook handles default values and basic data normalization:

// File: src/collections/Orders/hooks/beforeValidate.ts
import type { CollectionBeforeValidateHook } from "payload";
import type { Order } from "@payload-types";
import { LineItemInput } from "../lib/types";

export const beforeValidate: CollectionBeforeValidateHook<Order> = async ({ data }) => {
  if (!data) return data;

  data.currencyCode = data.currencyCode || "EUR";
  data.financialStatus = data.financialStatus || "pending";
  data.fulfillmentStatus = data.fulfillmentStatus || "unfulfilled";

  if (Array.isArray(data.lineItems)) {
    data.lineItems = data.lineItems.map((item: LineItemInput) => ({
      ...item,
      requiresShipping: item.requiresShipping === undefined ? true : item.requiresShipping,
      unitDiscount: item.unitDiscount ?? 0,
      lineTaxTotal: item.lineTaxTotal ?? 0,
      selectedOptions: Array.isArray(item.selectedOptions) ? item.selectedOptions : [],
    }));
  }

  return data;
};

This hook runs before validation, setting sensible defaults for fields that users might leave blank. It ensures every order has a currency code, a financial status, and properly formatted line items. By keeping this logic in its own file, you can modify default value behavior without touching the main collection config or other hooks.

The beforeChange hook handles more complex operations like generating order numbers and validating inventory:

// File: src/collections/Orders/hooks/beforeChange.ts
import type { CollectionBeforeChangeHook } from "payload";
import type { Order } from "@payload-types";
import { computeInventoryAdjustments, validateInventoryAdjustments } from "../lib/inventory";
import { normalizeLineItems, coerceExistingLineItems } from "../lib/lineItems";
import { calculateOrderTotals } from "../lib/totals";

export const beforeChange: CollectionBeforeChangeHook<Order> = async ({
  data,
  originalDoc,
  operation,
  req,
}) => {
  if (!data) return data;

  // Generate order number for new orders
  if (operation === "create" && !data.orderNumber) {
    const latestOrder = await req.payload.find({
      collection: "orders",
      sort: "-createdAt",
      limit: 1,
    });
    const latestOrderNumber = latestOrder.docs[0]?.orderNumber || "NAROCILO-0";
    const latestOrderNumberInt = parseInt(latestOrderNumber.replace("NAROCILO-", ""));
    data.orderNumber = `NAROCILO-${latestOrderNumberInt + 1}`;
  }

  // Normalize line items and calculate totals
  const normalizedLineItems = await normalizeLineItems({
    lineItems: data.lineItems,
    req,
  });

  const adjustments = computeInventoryAdjustments({
    newLineItems: normalizedLineItems,
    previousLineItems: coerceExistingLineItems(originalDoc?.lineItems),
  });

  await validateInventoryAdjustments(req, adjustments);

  const totals = calculateOrderTotals({
    lineItems: normalizedLineItems,
    shippingTotalInput: data.shippingTotal ?? originalDoc?.shippingTotal ?? 0,
  });

  data.lineItems = normalizedLineItems;
  data.subtotal = totals.subtotal;
  data.grandTotal = totals.grandTotal;

  return data;
};

This hook orchestrates several operations, but notice it doesn't implement the business logic directly. Functions like computeInventoryAdjustments, normalizeLineItems, and calculateOrderTotals are imported from the lib folder. The hook coordinates these operations, while the actual implementation lives elsewhere. This separation keeps hooks focused on orchestration rather than implementation.

Business Logic in the Lib Folder

The lib folder contains pure business logic that could theoretically be tested without any Payload infrastructure. This is where complex calculations, transformations, and validations live.

For example, the inventory adjustment logic needs to compare line items between the current order state and the previous state, determine what inventory changes are needed, and validate that sufficient stock exists:

// File: src/collections/Orders/lib/inventory.ts
import type { PayloadRequest } from "payload";

export function computeInventoryAdjustments({
  newLineItems,
  previousLineItems,
}: {
  newLineItems: NormalizedLineItem[];
  previousLineItems: NormalizedLineItem[];
}): InventoryAdjustment[] {
  const adjustments: InventoryAdjustment[] = [];

  // Find items that were added or had quantity increased
  for (const newItem of newLineItems) {
    const previousItem = previousLineItems.find(
      (prev) =>
        prev.productId === newItem.productId &&
        prev.variantId === newItem.variantId &&
        prev.batchId === newItem.batchId
    );

    const quantityChange = newItem.quantity - (previousItem?.quantity || 0);

    if (quantityChange > 0) {
      adjustments.push({
        batchId: newItem.batchId,
        productId: newItem.productId,
        variantId: newItem.variantId,
        quantityDelta: quantityChange,
        type: "reserve",
      });
    }
  }

  return adjustments;
}

export async function validateInventoryAdjustments(
  req: PayloadRequest,
  adjustments: InventoryAdjustment[]
): Promise<void> {
  for (const adjustment of adjustments) {
    const batch = await req.payload.findByID({
      collection: adjustment.batchCollection,
      id: adjustment.batchId,
    });

    const productEntry = batch.products.find(
      (p) => p.product === adjustment.productId
    );

    if (!productEntry || productEntry.availableStock < adjustment.quantityDelta) {
      throw new Error(`Insufficient stock for product ${adjustment.productId}`);
    }
  }
}

By keeping this logic in a dedicated file, you can understand the inventory system without wading through hook boilerplate. You can write unit tests that call these functions directly. And when you need to modify how inventory adjustments work, you know exactly where to look.

Validation Functions and Field-Level Logic

Some Payload collections need custom validation for specific fields. These validators can live in the hooks folder alongside lifecycle hooks, since they're still part of the collection's behavior:

// File: src/collections/SharedPoolBatches/hooks/validateBbeDate.ts
import type { DateFieldValidation } from "payload";
import type { Product } from "@payload-types";

export const validateBbeDate: DateFieldValidation = async (
  value,
  { data, req, operation }
) => {
  const productEntry = data as any;
  const productValue = productEntry.product;

  if (!productValue) return true;

  const product = await req.payload.findByID({
    collection: 'products',
    id: productValue,
  }) as Product;

  if (product.isPerishable && !value) {
    return 'BBE date is required for perishable products';
  }

  if (value && operation === 'create') {
    const bbe = new Date(value);
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    if (bbe < today) {
      return 'Cannot create batch with past BBE date';
    }
  }

  return true;
};

This validator can then be imported and used in the collection config:

// File: src/collections/SharedPoolBatches/index.ts
import { validateBbeDate } from "./hooks/validateBbeDate";

export const SharedPoolBatches: CollectionConfig = {
  fields: [
    {
      name: "bbeDate",
      type: "date",
      validate: validateBbeDate,
    },
  ],
};

The validator has access to the full Payload request object, allowing it to fetch related data and make complex validation decisions. But it's still just a function, making it easy to test and reason about.

When to Create a Folder Structure

Not every collection needs this level of organization. A simple content collection with no hooks and minimal logic can stay as a single file. The folder structure makes sense when you have complexity that would otherwise scatter across the codebase.

Create a folder structure when your collection has two or more lifecycle hooks, when it has business logic that exceeds a couple hundred lines, when it needs custom validators or field-level helpers, or when it defines types and utilities that are used across multiple hooks.

Keep the collection as a single file when it's primarily just field definitions, when it has no custom hooks, or when the total code is under a hundred lines. A collection that only reads data and displays it doesn't need the overhead of multiple files.

The Orders collection needed this structure because it orchestrates inventory management, customer creation, order number generation, and total calculations. The SharedPoolBatches collection needed it because it validates product allocation modes, calculates expiry dates, and manages stock levels. But a simple Banners collection that just stores images and text might not.

Migrating an Existing Collection

If you have an existing collection that's grown unwieldy, the migration follows a clear pattern. Start by creating the folder structure with hooks, lib, and fields subdirectories. Move your collection file into the new folder as index.ts. Then systematically extract each hook into its own file in the hooks folder.

Look at your imports. Any utilities or business logic that's specific to this collection should move from the global lib folder into the collection's lib folder. Update the import paths from absolute paths like @/lib/orders/inventory to relative paths like ../lib/inventory. This makes it clear the code is local to the collection.

Finally, update any external files that import your collection. Most of the time this is just the main Payload config, which should import from the folder rather than a specific file. Payload will automatically use the index.ts file.

The entire process for the Orders collection took about thirty minutes, and most of that was carefully updating import paths. The result was immediately noticeable - finding code became intuitive, and the collection's purpose was clear from looking at the folder structure.

The Benefits Compound Over Time

This structure might feel like overhead when you're building your first collection. But as your project grows, the benefits compound. When you add a fifth collection, then a tenth, you'll appreciate having a consistent pattern. When someone new joins the project, they can understand how one collection works and immediately understand all of them.

Debugging becomes faster because you're not constantly context-switching between directories. Code reviews get easier because changes are localized to a single folder. Refactoring is safer because you can see all the dependencies at a glance.

Most importantly, your codebase stays maintainable as it scales. The Orders collection in this project handles complex multi-step workflows with inventory tracking, customer management, and financial calculations. Despite that complexity, anyone can open the Orders folder and understand the complete system in minutes.

Moving Forward

The colocation principle applies beyond just collections. You can use the same approach for blocks, globals, and plugins. Keep related code together, organize by feature rather than by technical concern, and let your folder structure tell the story of what your application does.

If you're starting a new Payload project, consider using this structure from the beginning. If you have an existing project that's becoming hard to navigate, try refactoring one collection as an experiment. Pick your most complex collection - if the structure works there, it will work everywhere.

The documentation I created during this refactoring includes the complete folder structure template and migration checklist. But the core idea is simple: when code works together, it should live together. Your future self will thank you for the clarity.

Let me know in the comments if you have questions about implementing this structure in your own projects, and subscribe for more practical Payload CMS development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

How to Structure Payload CMS Collections for Long-Term Maintainability | Build with Matija