Page Object Model Design
The Page Object Model is the pattern that keeps a Playwright suite maintainable as it grows past a handful of specs. Instead of scattering selectors across every test, you encapsulate each screen's locators and user actions inside a class that receives the Page through its constructor. Tests then express intent — loginPage.submitCredentials(user, pass) — while the brittle details of how an element is found live in exactly one place. When the UI is refactored, you edit one class, not fifty specs. This guide covers the architectural rules, how to write async action methods that wait correctly, how to wire page objects into fixtures, how to return typed page objects from navigation, and how to structure them by feature domain so they compose at scale. It sits beneath Playwright Setup & Core Architecture.
Why flat spec files stop scaling
A suite written without an abstraction layer starts fast and then decays on a predictable curve. The first twenty specs each declare their own locators inline, and because every author picks a slightly different query for the same element, the same submit button is addressed as #submit, .btn-primary, and button:has-text("Sign In") in three different files. None of those are wrong in isolation. The problem is arithmetic: when the design system renames btn-primary, the change is a single line in the application and an unbounded number of lines in the test repository, and nothing in the tooling tells you how many until the run goes red.
The second failure is duplicated choreography. Logging in is six statements — navigate, fill two fields, click, wait for the redirect, dismiss a first-run banner — and those six statements get pasted into every spec that needs an authenticated session. When the banner gains an animation, or the redirect adds an interstitial, every copy needs the same edit. Teams typically respond by extracting a helpers.ts full of loose functions, which relieves the symptom without fixing the structure: the helpers still take raw selectors as arguments, still have no owner, and still grow a parameter for every caller's special case.
The third failure is the one that hurts most in review. A spec crowded with page.locator() calls reads as a transcript of DOM operations, not as a statement of behaviour, so a reviewer cannot tell whether the test asserts the right thing without mentally re-rendering the page. The Page Object Model attacks all three at once by naming a boundary: everything above it speaks in user intent, everything below it speaks in locators, and the two never mix. That boundary is what makes a suite reviewable, greppable, and cheap to refactor — the same layering discipline the rest of Playwright Setup & Core Architecture applies to configuration and isolation.
Architectural foundations
A clean page object obeys a few rules. Map each route or feature domain to one class. Receive the Page instance through the constructor so the object is bound to a single isolated context. Declare every locator once as a readonly field, built with Playwright's role- and label-based queries so resolution stays unambiguous. Expose only public async methods for user actions and state queries, and keep raw selectors private to the class. The result is a layer where test code never names a CSS string directly.
Constructing locators in the constructor rather than inside each method matters more than it looks. A Locator in Playwright is a lazy description of how to find an element, not a handle to a node — nothing is queried until you act on it or assert against it. That means field initialization is free, it costs no round trip to the browser, and it gives you one obvious place to read when you want to know what a screen is made of. It also rules out the elementHandle style entirely: handles bind to a specific node in a specific render, so they go stale the moment a framework re-renders the subtree, whereas a re-evaluated locator simply resolves against whatever is there now.
The rule that decides most design arguments is the direction of the dependency. Page objects may know about locators, other page objects, and component objects. They must not know about test data factories, assertions about business rules, or the runner's configuration. Specs may know about page objects and business assertions, and must not know about locators. Drawing that line once removes the recurring debate about whether a given helper "belongs" in the page object — you can answer it by asking which side of the boundary the knowledge lives on.
This topology eliminates selector duplication and centralizes maintenance. The selectors themselves should follow the resilience rules in Reliable Selector Strategies for Playwright: prefer accessible roles and stable text anchors over auto-generated classes, because a page object is only as durable as the locators it wraps. When two elements answer the same role-and-name query, resolve it inside the class with filter() or a scoped ancestor rather than pushing the ambiguity out to callers — the techniques in Resolving Strict Mode Violations apply directly.
Prerequisites
Everything below assumes a TypeScript project on @playwright/test with strict enabled in tsconfig.json, because the pattern's main safety net is the compiler catching a misspelled method or a Promise you forgot to await. Enable noImplicitAny and turn on the no-floating-promises lint rule; an un-awaited action method is the single most common source of a page object that "randomly" fails. You also want baseURL set in playwright.config.ts so page objects can navigate with relative paths like /auth/login and stay portable between local, preview, and CI environments — see Playwright Config & Fixtures for the full configuration surface.
Async action methods with built-in waiting
Every method on a page object is asynchronous and returns a typed Promise. The discipline that keeps these methods reliable is never sleeping — Playwright's locators auto-wait for actionability, and any genuine synchronization point should be an explicit condition like waitForURL() rather than a fixed delay. A login method, for example, fills the form, clicks submit, and then asserts the navigation it expects, so the method only resolves once the app has actually moved on.
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly username: Locator;
readonly submit: Locator;
constructor(page: Page) {
this.page = page;
// Role/label locators survive class and id churn.
this.username = page.getByRole('textbox', { name: /username/i });
this.submit = page.getByRole('button', { name: 'Sign In' });
}
async navigate(): Promise<void> {
await this.page.goto('/auth/login');
}
async submitCredentials(user: string, pass: string): Promise<void> {
await this.username.fill(user);
await this.page.getByLabel('Password').fill(pass);
await this.submit.click();
// Resolve only when the app has actually navigated — no sleeps.
await this.page.waitForURL('**/dashboard');
}
}
Method granularity is a real design decision, not a matter of taste. Write one method per user-meaningful action, not one per DOM event: submitCredentials() rather than fillUsername(), fillPassword(), and clickSubmit() called in sequence by every spec. The finer decomposition looks reusable but simply relocates the choreography back into the tests. The exception is when a spec needs to observe an intermediate state — validating that the submit button is disabled until both fields are populated, say — in which case exposing the individual locators as readonly fields lets the spec assert on them without giving it a selector.
Query methods deserve the same care. Return domain types rather than strings scraped from the DOM: a method called orderTotal() should return Promise<number> after parsing the currency, so twenty specs do not each re-implement the same parseFloat(text.replace('$', '')). Where a value only exists after an async transition, prefer an assertion-friendly shape — return the Locator and let the caller use the auto-retrying matchers from Web-First Assertions — because a raw textContent() read is a single-shot sample that races the render.
Two edge cases recur. First, actions that open a new tab or trigger a download must capture the event before the click, since the event can fire faster than the next statement runs; wrap them with page.waitForEvent() in a Promise.all() inside the method so callers cannot get the ordering wrong. Second, methods that trigger an optimistic UI update followed by a server confirmation should wait on the confirmed state, not the optimistic one, or the next action in the test will run against a row that is about to be replaced.
Integrating page objects with fixtures
Instantiating a page object by hand in every test reintroduces boilerplate. The fix is a fixture: extend the base test object with a factory that builds the page object, optionally drives it to a known state, and hands it to the test body. The runner then injects a ready-to-use object per spec, and tears down its context afterward, so isolation is preserved automatically.
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
type Fixtures = { loginPage: LoginPage };
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate(); // arrive at a known starting state
await use(loginPage); // hand the ready object to the test
},
});
test('redirects to the dashboard after login', async ({ loginPage }) => {
await loginPage.submitCredentials('admin', 'securePass');
await expect(loginPage.page).toHaveURL(/dashboard/);
});
The ordering below is what makes the pattern safe under parallelism: the fixture body runs inside the worker that will execute the test, so the Page it closes over belongs to that worker's context and to no other.
use() hands it to the test, so the spec body starts from a known screen.The fixture lifecycle — worker versus test scope, dependency chaining, and shared use() configuration — is the domain of Playwright Config & Fixtures. Because each fixture-built page object binds to a fresh context, the isolation guarantees from Browser Contexts & Isolation carry through unchanged, keeping parallel workers from leaking state across page objects.
Keep page object fixtures test-scoped, not worker-scoped. A page object holds a Page, and a Page cannot outlive its context; promoting it to worker scope produces objects that appear to work until a test closes the page underneath them. Expensive preparation that genuinely should be shared — seeding a database, minting an API token — belongs in a separate worker-scoped fixture that the test-scoped page object depends on, a split covered in Worker-Scoped Fixtures for Expensive Setup. Authentication in particular should not be re-driven through the UI by every fixture; restore a saved session instead, as described in Authentication & Session State, and let the fixture hand the test a page object that is already signed in.
Structuring for scale
A monolithic pages/ folder collapses under its own weight once the app has dozens of screens. Organize page objects by feature domain — auth/, checkout/, admin/ — rather than by raw URL, so shared components like a data grid or a modal live in one reusable class instead of being copied per route. Compose larger flows from smaller objects, and reuse a DataGrid component object across every screen that renders a table.
import { type Page, type Locator, expect } from '@playwright/test';
export class DataGrid {
readonly page: Page;
readonly rows: Locator;
constructor(page: Page) {
this.page = page;
this.rows = page.locator('tr.data-row');
}
async waitForData(timeout = 10_000): Promise<void> {
// Wait for the first row to attach, then for the count to settle.
await this.rows.first().waitFor({ state: 'attached', timeout });
await expect(this.rows).toHaveCount(await this.rows.count(), { timeout });
}
async cellText(row: number): Promise<string> {
return (await this.rows.nth(row).textContent()) ?? '';
}
}
Resist the urge to introduce a deep inheritance chain. A BasePage holding the shared header, footer, and a waitForToast() helper is useful; a four-level hierarchy where AdminOrdersPage extends OrdersPage extends ListPage extends BasePage is not, because behaviour that differs by one method forces you to override methods whose preconditions you can no longer see. Composition scales better: give each page object the components it contains as fields, and reserve the base class for genuinely universal chrome. Enable test.describe.configure({ mode: 'parallel' }) so independent specs distribute across CI shards, and attach trace snapshots on failure for diagnosis. The full directory topology, component-reuse strategy, and large-repository patterns are documented in Structuring Large Projects with the Page Object Model.
Component objects and composition
Not every reusable piece is a full page. Modals, navigation bars, date pickers, and data grids appear across many screens, and modeling each as a component object — a class that wraps a root locator and exposes the actions for that widget — avoids duplicating its logic in every page that hosts it. A page object then composes the components it contains rather than re-declaring their selectors, which is the single biggest lever for keeping a large suite small.
import { type Page, type Locator } from '@playwright/test';
import { DataGrid } from './components/DataGrid';
export class OrdersPage {
readonly page: Page;
readonly grid: DataGrid; // composed, not re-implemented
readonly newOrder: Locator;
constructor(page: Page) {
this.page = page;
this.grid = new DataGrid(page); // reuse the component everywhere a grid appears
this.newOrder = page.getByRole('button', { name: 'New order' });
}
async openLatestOrder(): Promise<void> {
await this.grid.waitForData();
await this.grid.rows.first().click();
}
}
Scoping a component to a root locator with locator.locator(...) also makes it safe when two instances of the same widget appear on one page — each component object queries only inside its own subtree, so the locators never collide. Prefer constructors that accept a root Locator rather than the whole Page for exactly this reason: new DataGrid(page.getByRole('region', { name: 'Open orders' })) can coexist with a second grid in the archived-orders region, whereas a page-rooted component silently matches both and trips strict mode. This scoping discipline mirrors the unambiguous selector rules in getByRole & Accessibility Selectors.
Returning typed page objects from navigation
The pattern most often missing from a half-finished implementation is making navigation itself type-checked. When a method causes the app to move to a different screen, have it return the page object for the destination instead of void. The spec then reads as a chain of screens, the compiler rejects a call to orderDetail.approve() on a page that has no approve button, and there is exactly one place that knows which class models the screen you land on. It also removes the most common source of stale locators, where a test keeps using the old page object after the app has navigated away.
import { type Page, type Locator, expect } from '@playwright/test';
import { OrdersPage } from './OrdersPage';
export class DashboardPage {
readonly page: Page;
readonly ordersLink: Locator;
readonly greeting: Locator;
constructor(page: Page) {
this.page = page;
this.ordersLink = page.getByRole('link', { name: 'Orders' });
this.greeting = page.getByRole('heading', { level: 1 });
}
// Returning the destination's page object makes the route type-checked.
async openOrders(): Promise<OrdersPage> {
await this.ordersLink.click();
// Confirm we really landed before handing back the next screen's object.
await this.page.waitForURL('**/orders');
const orders = new OrdersPage(this.page);
await expect(orders.newOrder).toBeVisible(); // readiness, not business logic
return orders;
}
}
Two rules keep this from becoming circular-import spaghetti. First, a returning method must confirm arrival before constructing the destination object, otherwise you hand the caller a class whose locators point at a screen that has not rendered. Second, when two page objects legitimately navigate to each other, import types with import type and construct lazily inside the method body, so the cycle exists only in the type graph and never at runtime.
Where assertions belong
A recurring design question is whether assertions live in the page object or the test. The pragmatic answer is both, split by purpose. Keep business-level assertions — "the order total equals the sum of line items" — in the test, because they express what the spec is verifying. Push readiness assertions — "the grid has finished loading," "the modal is open" — into the page object's methods, because they are preconditions for the action, not the thing under test. This split keeps tests readable as statements of intent while ensuring that an action method never proceeds against a half-rendered screen. It also means a method like waitForData() can be reused by twenty specs without each one re-implementing the same readiness check.
The distinction has a practical test: if the assertion failing would mean "the app is broken in the way this spec was written to detect," it belongs in the spec. If it failing would mean "the test arrived too early," it belongs in the page object. A useful side effect is that failure messages become self-describing — a readiness assertion inside openOrders() reports that the orders screen never rendered, which is a far more actionable message than a timeout on a click three statements later in the spec.
Failure modes and debugging
Most page object failures trace back to two habits. The first is wrapping fragile selectors — auto-generated ids, hashed class names — which makes the whole abstraction brittle; audit locators and replace them with semantic roles, accessible labels, and stable text. The second is reintroducing implicit timing through page.waitForTimeout(); replace it with auto-retrying locators and expect.poll() for conditions that genuinely need polling. Keep test data generation scoped per context so outcomes stay deterministic across distributed runners, and lean on strict locator mode, which throws on ambiguous matches during development before they reach CI.
Three subtler failures show up once a suite is large. A shared mutable field on a page object — caching a row count in the constructor, or storing "the currently open modal" — turns the object into hidden state that leaks between actions; keep page objects stateless apart from their locators. A method that swallows an error to "make the test more robust", typically a try/catch around a dismissal of an optional banner, converts a real regression into a silent pass; if an element is genuinely optional, express that with if (await banner.isVisible()) rather than catching. And a page object that reaches for page.waitForLoadState('networkidle') as a general-purpose settle is trading determinism for luck on any screen with polling or analytics beacons.
When a page object method fails, the trace is the fastest route to the cause because it records the locator resolution for each step in order — you can see whether the chain matched zero elements, matched two, or matched one that was covered by an overlay. Open the failing action in the Trace Viewer & Debugging workflow and compare the before/after DOM snapshots around the click. If a method fails intermittently rather than consistently, treat it as a synchronization defect in the page object rather than a test defect, and apply the triage sequence in Flaky Test Management.
CI/CD considerations
Page objects change how a suite behaves under sharding. Because each object binds to a context created by a test-scoped fixture, specs remain independent by construction, which is the precondition for splitting a run across machines at all — a suite that shares a logged-in page across files cannot be sharded without reordering failures. Keep that property honest by running the suite locally with --shard=1/4 occasionally; if a shard fails alone but passes in a full run, a page object is carrying state it should not.
Two conventions pay for themselves in CI. First, put the page object layer under the same lint and type-check job as production code, so a broken locator field fails in seconds instead of after a browser download and a ten-minute run. Second, treat data-testid attributes that page objects depend on as an API between the application and the test repository — deleting one should fail a build, not a nightly. The container image, browser caching, and shard configuration that carry this into a pipeline are covered in CI/CD Integration.
Deep dives beneath this guide
Structuring Large Projects with the Page Object Model takes the composition rules above into a full repository layout — directory conventions by feature domain, where shared components live, how barrel exports stay manageable, and the refactoring path from an existing folder of flat specs.
Frequently Asked Questions
What exactly belongs inside a page object?
Locators for the screen's elements and async methods for the actions and queries a user performs on it. Keep raw selectors private and expose only intent-revealing methods. Assertions can live in the test or in dedicated verification methods, but the selectors themselves should never appear in spec files.
How do I avoid creating a page object by hand in every test?
Wrap it in a fixture. Extend the base test object with a factory that constructs the page object, drives it to a known state, and hands it to the test through use(). The runner then injects a ready instance per spec and tears down its context automatically.
Should I organize page objects by URL or by feature?
By feature domain. URL-based folders fragment shared components across routes, while feature folders let a single DataGrid or modal class be reused everywhere it appears. Compose larger flows from these smaller component objects to maximize reuse.
Should a page object method return the next page object?
Yes, whenever the action navigates. Returning the destination class makes the route type-checked, gives the compiler a chance to reject a call that skips a screen, and keeps knowledge of which class models which URL in one place. Confirm arrival with a URL wait or a visibility check before constructing and returning the object.
Is inheritance or composition the better way to share behaviour?
Composition, in nearly every case. A single base class for universal chrome such as a header or a toast helper is fine, but deep hierarchies force overrides whose preconditions are invisible at the call site. Give each page object the component objects it contains as fields instead, and scope each component to a root locator so repeated widgets never collide.
Do page objects make tests slower to run?
No. A Locator is a lazy description rather than a live handle, so declaring fields in a constructor costs nothing until an action or assertion evaluates them. The measurable cost of a page object layer is compile time, not runtime, and it is usually offset by removing redundant waits that copied-and-pasted spec code accumulates.