FRAMEWORKSINTERMEDIATE

Page Object Model with Playwright: A Practical Guide

Structure Playwright tests using Page Objects — reduce duplication, isolate selectors, and make refactors painless.

iff Solution Academy July 3, 2026 14 min read Updated July 8, 2026
Playwright Page Object Model POM Architecture

Why POM Matters

The Page Object Model (POM) is the most widely adopted design pattern in UI test automation, and for good reason. Selectors change, workflows are renamed, and product teams ship UI redesigns constantly. Without a layer of abstraction between your tests and the DOM, every visual change can break dozens of tests and force painful search-and-replace across your entire suite.

POM solves this by encapsulating the structure and behaviour of a single screen (or reusable component) inside a class. Tests describe intent — 'log in as admin', 'add product to cart' — while page objects own the messy details of locators, waits, and navigation. When the UI changes, you edit one file instead of one hundred.

In enterprise Playwright projects, POM is the single biggest factor in keeping a suite maintainable past 500 tests. Combined with fixtures (covered below), it turns test files into readable specifications your product managers can actually review.

Prerequisites

  • Node.js 18+ installed and a working Playwright project (npm init playwright@latest).
  • Comfort with TypeScript classes, async/await, and ES modules.
  • Familiarity with Playwright locators — especially getByRole, getByLabel and getByTestId.
  • A test app to point at (the Playwright demo TodoMVC or your own staging environment).

A Basic Page Object

A page object is just a class that takes the Playwright Page in its constructor, exposes locators as readonly properties, and provides methods for the high-level actions a user can perform on that screen.

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

export class LoginPage {
  readonly username: Locator;
  readonly password: Locator;
  readonly submit: Locator;

  constructor(private page: Page) {
    this.username = page.getByLabel('Username');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
  }

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

  async loginAs(user: string, pass: string) {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
  }
}

Notice three things: locators are defined once in the constructor (not re-queried in every method), the action method loginAs describes user intent rather than mechanics, and there are no assertions inside the class — those belong in the test.

Using It in a Test

ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('admin can log in', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.loginAs('admin', 'secret');
  await expect(page).toHaveURL(/dashboard/);
});

Read that test aloud. It describes what a human user does — no CSS selectors, no waiting logic, no clicks by coordinate. If marketing renames 'Sign in' to 'Log in' tomorrow, you fix one line in LoginPage.ts and every test that uses it stays green.

Level Up with Fixtures

Manually calling new LoginPage(page) in every test gets repetitive. Playwright fixtures let you inject fully-constructed page objects the same way the built-in page fixture works.

fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

type Pages = { login: LoginPage; dashboard: DashboardPage };

export const test = base.extend<Pages>({
  login: async ({ page }, use) => use(new LoginPage(page)),
  dashboard: async ({ page }, use) => use(new DashboardPage(page)),
});
export { expect } from '@playwright/test';
ts
import { test, expect } from '../fixtures';

test('admin sees dashboard', async ({ login, dashboard, page }) => {
  await login.goto();
  await login.loginAs('admin', 'secret');
  await expect(dashboard.welcomeBanner).toBeVisible();
});

Composition Over Inheritance

Beginners often reach for a BasePage class with shared helpers. Resist that reflex. Inheritance chains get tangled fast, and shared 'header' or 'nav' widgets are almost always better modelled as their own component objects that a page object composes.

ts
export class DashboardPage {
  readonly nav = new NavComponent(this.page);
  readonly welcomeBanner: Locator;
  constructor(private page: Page) {
    this.welcomeBanner = page.getByRole('heading', { name: /welcome/i });
  }
}

Common Pitfalls

  • Putting assertions inside page objects — mixes the responsibility of 'act' and 'verify' and hides failures inside deep stack traces.
  • Returning raw ElementHandles from getters — they go stale on re-render. Return Locators.
  • One giant PageObject per feature — split it. If a class has 30 methods, it is really 3 classes.
  • Hardcoded waits (page.waitForTimeout) — rely on Playwright's auto-waiting instead.
  • Duplicating selectors across page objects and tests — the whole point of POM is that selectors live in exactly one place.

Debugging Tips

  • Run with --debug or PWDEBUG=1 to step through page object methods interactively.
  • Enable trace: 'on-first-retry' in playwright.config.ts and inspect failures in the Trace Viewer — you can see exactly which locator inside your page object failed.
  • Log the resolved locator with await locator.evaluate(el => el.outerHTML) when a selector unexpectedly matches the wrong element.
  • Use page.pause() inside a method while iterating on it — the Playwright Inspector lets you tweak selectors live.

When to Use — and When Not To

Use POM when your suite has more than a handful of tests, when the UI changes with any regularity, or when multiple engineers contribute to the same codebase. It is the default for enterprise projects.

Skip POM (or use only lightweight component helpers) for tiny throwaway spikes, one-off smoke tests, or when you are automating a stable third-party UI that will never change. Over-engineering a 5-test suite with a POM layer is real waste.

FAQ

Should page objects extend a common BasePage?

Usually no. Prefer composing shared components (Header, Nav, Modal) into pages. Inheritance couples pages that have no real 'is-a' relationship.

Where do I put shared UI components?

Under components/ next to your pages/ folder. A HeaderComponent that owns the nav locators is instantiated inside every page object that displays a header.

Can page objects call the API?

They can, but prefer to inject an APIRequestContext through a fixture. Keep 'talking to the UI' and 'talking to the backend' as separate collaborators — it makes tests easier to reason about.

How do I handle navigation between pages?

An action that navigates away can return the new page object: async openCart(): Promise<CartPage> { await this.cartLink.click(); return new CartPage(this.page); }

Should page objects use data-testid attributes?

Prefer accessible locators (getByRole, getByLabel) because they double as an accessibility guardrail. Fall back to data-testid only for elements that genuinely have no accessible name — icon buttons, decorative wrappers, third-party embeds. When you do use data-testid, define it in the page object, never in the test.

How big should a page object get?

As a rule of thumb, if a page object grows past 300 lines or exposes more than a dozen actions, the screen it models is probably really two or three collaborating components. Extract them. Small, focused classes are easier to reason about, easier to reuse, and easier to delete when the product changes.

Do I need a separate page object for every URL?

Usually yes — one page object per user-visible screen keeps responsibilities clean. Two exceptions: modals and drawers that appear over any page are better modelled as component objects, and multi-step wizards can share a single page object per step or one aggregate object with clearly named methods per step.

Summary

The Page Object Model is the backbone of any Playwright suite that has to survive real product change. Encapsulate locators and behaviour per screen, keep assertions in tests, inject page objects with fixtures, and prefer composition over inheritance. Do that and your tests read like specifications — while a UI redesign turns from a week of firefighting into a single well-scoped pull request.

The framework does not stop you from writing tests without page objects — Playwright is happy to run raw scripts. But every team we have worked with that scaled past a hundred tests eventually landed on some flavour of POM, because the alternative is a tangled ball of duplicated selectors that resists every UI redesign. Save yourself that migration and adopt the pattern from day one.

Combine everything in this guide — small focused page objects, composition over inheritance, fixtures for dependency injection, assertions kept firmly in the tests — and you have the foundation that the rest of an enterprise framework (custom reporters, tagging, data-driven cases, CI parallelism) builds on top of. Master POM first, and the rest of the automation stack falls into place around it.

Get Playwright tutorials in your inbox

Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.

Recommended Next Articles