Skip to content
HN
Back to blog
POMDesign Patterns

Page Object Model Design, Beyond the Basics

Moving from 'one class per page' to composable, reusable page components.

July 30, 20266 min read

Page Object Model Design, Beyond the Basics

Introduction

The Page Object Model (POM) has become one of the most widely adopted design patterns in UI test automation. Almost every Playwright, Selenium, or Cypress project starts with page objects that encapsulate locators and user interactions.

However, many automation frameworks stop at the basics.

Over time, these projects often suffer from:

  • Massive page classes with hundreds of methods.
  • Duplicated locators.
  • Tight coupling between tests and page implementations.
  • Difficult maintenance as applications grow.

A mature automation framework requires more than simply moving locators into separate files—it requires thoughtful architecture.

In this article, we'll explore advanced Page Object Model design principles that make Playwright frameworks scalable, maintainable, and easy to extend.


What is the Page Object Model?

The Page Object Model is a design pattern where each page of an application is represented by a class.

Instead of writing:

await page.locator('#username').fill('tester');
await page.locator('#password').fill('Password123');
await page.locator('button').click();

You write:

await loginPage.login(
    'tester',
    'Password123'
);

The test focuses on business behaviour, while the page object hides implementation details.


The Problem with Basic POM

Many projects eventually evolve into something like this:

LoginPage

├── 80 locators
├── 60 methods
├── 1,500 lines

Every new feature adds more methods.

Eventually the page becomes difficult to understand and even harder to maintain.


Design Around Business Components

Modern web applications are composed of reusable UI components.

Instead of treating an entire page as one object:

Home Page

├── Header
├── Navigation
├── Search Bar
├── Product List
├── Footer

Create reusable component objects.

components/

├── Header.ts
├── Sidebar.ts
├── Modal.ts
├── ProductCard.ts
├── Pagination.ts

Pages become compositions of components rather than giant classes.


Keep Pages Focused

A page object should represent one page—not the entire application.

Avoid:

class DashboardPage {

    login(){}

    logout(){}

    search(){}

    deleteUser(){}

    updateSettings(){}

}

Instead:

DashboardPage

↓

Uses

↓

HeaderComponent

↓

UserMenuComponent

↓

SearchComponent

↓

SettingsComponent

Each class has a single responsibility.


Separate Locators from Actions

Many frameworks mix locators and business logic together.

Example:

class LoginPage {

    username =
        page.getByLabel('Username');

    password =
        page.getByLabel('Password');

    loginButton =
        page.getByRole('button', {
            name: 'Login'
        });

}

Then:

async login(user, pass) {

    await this.username.fill(user);

    await this.password.fill(pass);

    await this.loginButton.click();

}

This separation improves readability and maintainability.


Avoid Assertion Logic Inside Page Objects

A common anti-pattern:

async login() {

    ...

    expect(
        this.successMessage
    ).toBeVisible();

}

Why is this problematic?

Page Objects should perform actions—not verify business outcomes.

Instead:

await loginPage.login();

await expect(
    dashboardPage.heading
).toBeVisible();

Assertions belong in test cases or dedicated assertion helpers.


Return Meaningful Objects

Instead of:

await loginPage.login();

Return the next logical page.

const dashboard =
    await loginPage.login(
        username,
        password
    );

Example:

async login(
    username,
    password
): Promise<DashboardPage> {

    ...

    return new DashboardPage(
        this.page
    );

}

This models the user journey naturally.


Build Reusable Components

Consider a confirmation modal.

Without reusable components:

Delete User Page

↓

Delete Modal

↓

Delete Product Page

↓

Delete Modal

↓

Delete Order Page

↓

Delete Modal

Three duplicated implementations.

Instead:

ConfirmationModal

↓

Used Everywhere

One component.

Many pages.


Introduce a Base Page Carefully

A Base Page can eliminate duplicated functionality.

Example:

class BasePage {

    async waitForLoading(){}

    async takeScreenshot(){}

    async click(){}

    async fill(){}

}

Avoid placing page-specific logic inside the base class.

A bloated Base Page becomes just another maintenance problem.


Avoid Generic Methods That Hide Intent

Bad example:

click(locator);

Better:

clickCheckoutButton();

Tests should read like user behaviour.


Keep Business Logic Out of Tests

Instead of:

await page.fill(...);

await page.click(...);

await page.waitForTimeout(...);

await page.click(...);

Write:

await checkoutPage.placeOrder();

The implementation may change.

The business action remains the same.


Handle Dynamic Elements Gracefully

Modern applications frequently display:

  • Toast messages
  • Loading indicators
  • Animations
  • Lazy-loaded components

Encapsulate synchronization inside page objects.

Example:

async saveChanges() {

    await this.saveButton.click();

    await this.loadingSpinner.waitFor({
        state: 'hidden'
    });

}

Tests remain clean and readable.


Organize Large Frameworks

Example project structure:

src/

├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│
├── components/
│   ├── Header.ts
│   ├── Sidebar.ts
│   ├── Modal.ts
│
├── api/
│
├── fixtures/
│
├── utils/
│
├── data/
│
└── tests/

This structure scales much better than placing everything inside a single pages folder.


Composition Over Inheritance

Instead of creating deep inheritance trees:

BasePage

↓

AuthenticatedPage

↓

AdminPage

↓

UserManagementPage

Prefer composition.

DashboardPage

↓

HeaderComponent

↓

NotificationPanel

↓

NavigationMenu

Composition reduces coupling and increases flexibility.


Design for Readability

A well-written test should describe user behaviour.

Example:

await loginPage.login();

await dashboard.openReports();

await reports.exportSales();

await expect(
    reports.successToast
).toBeVisible();

Someone unfamiliar with the implementation should still understand the test.


Common POM Mistakes

Avoid these anti-patterns:

❌ Page objects containing assertions

❌ Hundreds of locators in one class

❌ Business logic duplicated across pages

❌ Hardcoded waits

❌ Exposing raw locators to tests

❌ Deep inheritance hierarchies

❌ Utility methods with unclear intent


Best Practices

1. Keep Classes Small

Each page or component should have a single responsibility.


2. Build Reusable Components

Extract repeated UI elements into shared component classes.


3. Encapsulate UI Details

Tests should never know how buttons or fields are located.


4. Return Page Objects

Methods that navigate should return the destination page object.


5. Avoid Assertions in Pages

Keep verification logic inside tests or dedicated assertion classes.


6. Prefer Composition

Use components to build pages rather than relying on deep inheritance.


7. Write Business-Oriented APIs

A method like:

checkoutPage.placeOrder();

is far more expressive than:

click(button);
fill(field);
click(confirm);

Real-World Example

Basic POM

await page.fill('#email', email);
await page.fill('#password', password);
await page.click('.login-btn');

Works—but tightly couples the test to the UI.


Well-Designed POM

const dashboard =
    await loginPage.login(
        email,
        password
    );

await dashboard.openProfile();

await profilePage.updateAddress();

await expect(
    profilePage.successMessage
).toBeVisible();

The test clearly communicates what the user is doing, while page objects handle how those actions are performed.


Conclusion

The Page Object Model is much more than a folder of page classes. As automation projects grow, thoughtful design becomes essential for maintaining readability, reducing duplication, and minimizing maintenance costs.

By breaking pages into reusable components, favoring composition over inheritance, separating actions from assertions, and exposing business-oriented APIs, you can build an automation framework that scales alongside your application.

Ultimately, a great Page Object Model isn't measured by the number of page classes it contains—it's measured by how easily your team can understand, extend, and maintain it as the product evolves.