When a navigation bar appears on every page, duplicating its locators in every page object is the first sign you need component objects: a NavigationBar class composed into BasePage so every page inherits nav.logout() without copying code. Multi-step flows need a different pattern: proceedToCheckout() returning a CheckoutPage and continueToPayment() returning a PaymentPage gives the type system an accurate map of where each action lands, so tests read like the flow itself. This guide covers component objects for shared UI like navbars and modals, the composition approach to BasePage, dynamic table components, typed page chains for multi-step flows, and the builder pattern for assembling test data without constructor sprawl.

The Problem with Naive POM

Basic POM works for simple pages. It breaks down when:

  • Navigation bar appears on every page — do you duplicate it in every page object?
  • A modal can appear from multiple pages — where does it live?
  • A complex multi-step form spans multiple pages — how do you chain them?
  • Locators become stale because the page structure changes frequently

Advanced POM patterns solve these problems.

Component Objects: Reusable UI Parts

Break out shared UI elements into their own classes:

// components/NavigationBar.ts
import { Page, Locator } from '@playwright/test';

export class NavigationBar {
  readonly page: Page;
  readonly userMenu: Locator;
  readonly notificationBell: Locator;
  readonly searchBar: Locator;
  readonly logo: Locator;

  constructor(page: Page) {
    this.page = page;
    this.userMenu = page.getByTestId('user-menu');
    this.notificationBell = page.getByTestId('notification-bell');
    this.searchBar = page.getByTestId('nav-search');
    this.logo = page.getByTestId('logo');
  }

  async logout() {
    await this.userMenu.click();
    await this.page.getByTestId('logout-option').click();
  }

  async goToProfile() {
    await this.userMenu.click();
    await this.page.getByTestId('profile-option').click();
  }

  async search(query: string) {
    await this.searchBar.fill(query);
    await this.searchBar.press('Enter');
  }
}

// components/ConfirmationModal.ts
import { Page, Locator } from '@playwright/test';

export class ConfirmationModal {
  readonly page: Page;
  readonly modal: Locator;
  readonly title: Locator;
  readonly message: Locator;
  readonly confirmButton: Locator;
  readonly cancelButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.modal = page.getByTestId('confirmation-modal');
    this.title = page.getByTestId('modal-title');
    this.message = page.getByTestId('modal-message');
    this.confirmButton = page.getByTestId('modal-confirm');
    this.cancelButton = page.getByTestId('modal-cancel');
  }

  async confirm() {
    await this.confirmButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async cancel() {
    await this.cancelButton.click();
    await this.modal.waitFor({ state: 'hidden' });
  }

  async waitForOpen() {
    await this.modal.waitFor({ state: 'visible' });
  }
}

Base Page with Composition

Instead of inheritance everywhere, compose pages from components:

// pages/BasePage.ts
import { Page } from '@playwright/test';
import { NavigationBar } from '../components/NavigationBar';
import { ConfirmationModal } from '../components/ConfirmationModal';

export abstract class BasePage {
  readonly page: Page;
  readonly nav: NavigationBar;
  readonly confirmModal: ConfirmationModal;

  constructor(page: Page) {
    this.page = page;
    this.nav = new NavigationBar(page);
    this.confirmModal = new ConfirmationModal(page);
  }

  abstract navigate(): Promise<void>;

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async getTitle() {
    return this.page.title();
  }
}

// pages/UserManagementPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class UserManagementPage extends BasePage {
  readonly userRows: Locator;
  readonly addUserButton: Locator;
  readonly searchInput: Locator;

  constructor(page: Page) {
    super(page);  // Sets up nav and confirmModal
    this.userRows = page.getByTestId('user-row');
    this.addUserButton = page.getByTestId('add-user');
    this.searchInput = page.getByTestId('user-search');
  }

  async navigate() {
    await this.page.goto('/admin/users');
    await this.waitForPageLoad();
  }

  async deleteUser(userId: number) {
    await this.page.getByTestId(`delete-user-${userId}`).click();
    await this.confirmModal.waitForOpen();
    await this.confirmModal.confirm();
  }
}

// In tests
test('admin can delete a user', async ({ page }) => {
  const usersPage = new UserManagementPage(page);
  await usersPage.navigate();
  
  const initialCount = await usersPage.userRows.count();
  await usersPage.deleteUser(123);
  
  await expect(usersPage.userRows).toHaveCount(initialCount - 1);
  // Nav is available too:
  await usersPage.nav.logout();
});

Dynamic Rows and Tables

Handle lists and tables with dynamic content:

// components/DataTable.ts
import { Page, Locator } from '@playwright/test';

export class DataTable {
  readonly rows: Locator;
  readonly headers: Locator;
  readonly sortButtons: Locator;
  readonly pagination: Locator;

  constructor(page: Page, tableTestId: string) {
    const table = page.getByTestId(tableTestId);
    this.rows = table.getByTestId('table-row');
    this.headers = table.getByTestId('table-header');
    this.sortButtons = table.getByTestId('sort-button');
    this.pagination = table.getByTestId('pagination');
  }

  getRow(index: number) {
    return this.rows.nth(index);
  }

  async getRowByText(text: string) {
    return this.rows.filter({ hasText: text });
  }

  async getCellValue(row: number, column: number) {
    return this.rows.nth(row)
      .getByTestId('table-cell')
      .nth(column)
      .textContent();
  }

  async sortBy(columnName: string) {
    await this.sortButtons.filter({ hasText: columnName }).click();
  }

  async getCount() {
    return this.rows.count();
  }
}

// In a page object
class OrdersPage extends BasePage {
  readonly ordersTable: DataTable;
  
  constructor(page: Page) {
    super(page);
    this.ordersTable = new DataTable(page, 'orders-table');
  }
}

// In tests
test('orders sorted by date', async ({ page }) => {
  const ordersPage = new OrdersPage(page);
  await ordersPage.navigate();
  
  await ordersPage.ordersTable.sortBy('Date');
  
  const firstDate = await ordersPage.ordersTable.getCellValue(0, 2);
  const secondDate = await ordersPage.ordersTable.getCellValue(1, 2);
  
  expect(new Date(firstDate!)).toBeGreaterThan(new Date(secondDate!));
});

Multi-Step Workflows: Page Chains

For multi-step flows like checkout, return the next page from each step:

// pages/checkout/CartPage.ts
import { Page } from '@playwright/test';
import { CheckoutPage } from './CheckoutPage';

export class CartPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }

  async navigate() {
    await this.page.goto('/cart');
  }

  async proceedToCheckout(): Promise<CheckoutPage> {
    await this.page.getByTestId('proceed-checkout').click();
    await this.page.waitForURL('/checkout/shipping');
    return new CheckoutPage(this.page);
  }
}

// pages/checkout/CheckoutPage.ts
import { Page } from '@playwright/test';
import { PaymentPage } from './PaymentPage';

export class CheckoutPage extends BasePage {
  readonly firstName: Locator;
  readonly lastName: Locator;
  readonly address: Locator;
  readonly city: Locator;
  
  constructor(page: Page) {
    super(page);
    this.firstName = page.getByTestId('first-name');
    this.lastName = page.getByTestId('last-name');
    this.address = page.getByTestId('address');
    this.city = page.getByTestId('city');
  }

  async fillShipping(details: ShippingDetails): Promise<void> {
    await this.firstName.fill(details.firstName);
    await this.lastName.fill(details.lastName);
    await this.address.fill(details.address);
    await this.city.fill(details.city);
  }

  async continueToPayment(): Promise<PaymentPage> {
    await this.page.getByTestId('continue-payment').click();
    await this.page.waitForURL('/checkout/payment');
    return new PaymentPage(this.page);
  }
}

// Test using the chain
test('complete checkout flow', async ({ page }) => {
  const cart = new CartPage(page);
  await cart.navigate();
  
  const checkout = await cart.proceedToCheckout();
  await checkout.fillShipping({
    firstName: 'John', lastName: 'Doe',
    address: '123 Main St', city: 'Portland',
  });
  
  const payment = await checkout.continueToPayment();
  await payment.fillCard({ number: '4242424242424242', expiry: '12/28', cvc: '123' });
  
  const confirmation = await payment.submitOrder();
  await expect(confirmation.orderNumber).toBeVisible();
});

The Builder Pattern for Complex Setup

When creating complex test data or filling complex forms:

class UserFormBuilder {
  private data: Partial<UserFormData> = {};

  withName(firstName: string, lastName: string) {
    this.data.firstName = firstName;
    this.data.lastName = lastName;
    return this;  // Fluent interface — returns this for chaining
  }

  withEmail(email: string) {
    this.data.email = email;
    return this;
  }

  withRole(role: 'admin' | 'member') {
    this.data.role = role;
    return this;
  }

  asAdmin() {
    this.data.role = 'admin';
    this.data.permissions = ['manage-users', 'view-reports'];
    return this;
  }

  build(): UserFormData {
    return {
      firstName: this.data.firstName ?? 'Test',
      lastName: this.data.lastName ?? 'User',
      email: this.data.email ?? `test_${Date.now()}@example.com`,
      role: this.data.role ?? 'member',
      permissions: this.data.permissions ?? [],
    };
  }
}

// In tests — very readable
const adminUser = new UserFormBuilder()
  .withName('Alice', 'Smith')
  .withEmail('alice@test.com')
  .asAdmin()
  .build();

const regularUser = new UserFormBuilder()
  .withEmail('bob@test.com')
  .build();  // Uses defaults for everything else

Lazy Locator Initialization

Compute locators once, on first access:

class ProductPage extends BasePage {
  private _productCards?: Locator;
  private _filterPanel?: Locator;
  
  get productCards(): Locator {
    if (!this._productCards) {
      this._productCards = this.page.getByTestId('product-card');
    }
    return this._productCards;
  }
  
  get filterPanel(): Locator {
    if (!this._filterPanel) {
      this._filterPanel = this.page.getByTestId('filter-panel');
    }
    return this._filterPanel;
  }
}

Summary

| Pattern | Problem it solves |

|---------|------------------|

| Component Objects | Shared UI (nav, modals) without duplication |

| Composition in BasePage | Common functionality without deep inheritance chains |

| DataTable class | Dynamic lists/tables with reusable row operations |

| Page chain pattern | Multi-step workflows with type-safe page transitions |

| Builder pattern | Complex test data and form filling |

| Lazy locators | Performance — compute only when needed |

The key principle: page objects should match how users think about the app, not how the DOM is structured. When your page objects read like a user story ("proceed to checkout," "fill shipping details," "submit order"), they're at the right abstraction level.

→ See also: Page Object Model in Playwright: From Messy to Maintainable | Advanced Page Object Patterns in Playwright | Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | TypeScript Interfaces and Types for Page Object Model