The basic Page Object Model breaks down when the same data table appears on five pages and its locators are duplicated five times, or when loginWith() returns Page instead of DashboardPage and tests have to guess which page they've landed on. This article covers the patterns that fix those problems: a BasePage abstract class for shared navigation and load-waiting, component objects for reusable UI sections like data tables, a PageFactory fixture that delivers all page objects through a single pages parameter, and fluent method chaining where methods return the next page object. It also covers when to stop: basic POM with one class per page is the right call for most projects under 50 tests.
Base page class
Every page object in your suite shares common behavior: navigating to the page, waiting for it to load, checking the page title. Extract this into a BasePage:
// pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
protected page: Page;
protected readonly url: string;
constructor(page: Page, url: string) {
this.page = page;
this.url = url;
}
async navigate() {
await this.page.goto(this.url);
await this.waitForLoad();
}
// Override in subclasses to wait for page-specific loading indicators
protected async waitForLoad() {
await this.page.waitForLoadState('domcontentloaded');
}
async getTitle() {
return this.page.title();
}
async getURL() {
return this.page.url();
}
}Concrete page:
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';
export class LoginPage extends BasePage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
super(page, '/login');
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByRole('alert');
}
protected async waitForLoad() {
await this.emailInput.waitFor({ state: 'visible' });
}
async loginWith(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
return new DashboardPage(this.page);
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}The return type DashboardPage makes the flow explicit: loginWith() returns the page you land on.
Component objects
Large pages have sections that repeat: a data table, a notification widget, a filter panel that appears on multiple pages. Extract these into component objects:
// components/DataTable.ts
import { Page, Locator } from '@playwright/test';
export class DataTable {
private readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
get rows() {
return this.container.getByRole('row');
}
async getRowCount() {
return this.rows.count();
}
async getRowByText(text: string) {
return this.rows.filter({ hasText: text });
}
async clickAction(rowText: string, actionName: string) {
const row = this.rows.filter({ hasText: rowText });
await row.getByRole('button', { name: actionName }).click();
}
async sortBy(columnName: string) {
await this.container
.getByRole('columnheader', { name: columnName })
.click();
}
}Use in page objects:
// pages/OrdersPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { DataTable } from '../components/DataTable';
export class OrdersPage extends BasePage {
readonly ordersTable: DataTable;
constructor(page: Page) {
super(page, '/orders');
this.ordersTable = new DataTable(
page.getByTestId('orders-table')
);
}
}Test:
test('orders table shows correct data', async ({ page }) => {
const ordersPage = new OrdersPage(page);
await ordersPage.navigate();
expect(await ordersPage.ordersTable.getRowCount()).toBeGreaterThan(0);
await ordersPage.ordersTable.clickAction('Order #123', 'View details');
});The same DataTable component can be reused in ProductsPage, UsersPage, or anywhere else a table appears.
Page object factories
When you have many page objects and tests need to navigate between them, a factory avoids repetitive instantiation:
// pages/PageFactory.ts
import { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { DashboardPage } from './DashboardPage';
import { OrdersPage } from './OrdersPage';
import { ProfilePage } from './ProfilePage';
export class PageFactory {
constructor(private page: Page) {}
get login() { return new LoginPage(this.page); }
get dashboard() { return new DashboardPage(this.page); }
get orders() { return new OrdersPage(this.page); }
get profile() { return new ProfilePage(this.page); }
}With a fixture:
// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { PageFactory } from '../pages/PageFactory';
export const test = base.extend<{ pages: PageFactory }>({
pages: async ({ page }, use) => {
await use(new PageFactory(page));
},
});Test:
import { test } from '../fixtures/pages';
import { expect } from '@playwright/test';
test('user can view their orders', async ({ pages }) => {
await pages.login.navigate();
const dashboard = await pages.login.loginWith('user@example.com', 'password');
await pages.orders.navigate();
expect(await pages.orders.ordersTable.getRowCount()).toBeGreaterThan(0);
});Fluent API (method chaining)
For test readability, methods can return this to allow chaining:
// pages/CheckoutPage.ts
export class CheckoutPage extends BasePage {
async fillShipping(address: ShippingAddress) {
await this.page.getByLabel('Street').fill(address.street);
await this.page.getByLabel('City').fill(address.city);
await this.page.getByLabel('Postal code').fill(address.postalCode);
return this;
}
async selectPaymentMethod(method: 'card' | 'paypal') {
await this.page.getByRole('radio', { name: method }).click();
return this;
}
async submit() {
await this.page.getByRole('button', { name: 'Place order' }).click();
return new ConfirmationPage(this.page);
}
}Test:
const confirmation = await checkoutPage
.fillShipping({ street: '123 Main St', city: 'Berlin', postalCode: '10115' })
.then(p => p.selectPaymentMethod('card'))
.then(p => p.submit());
await expect(confirmation.heading).toHaveText('Order confirmed!');When the basic POM is enough
Not every project needs component objects, factories, and fluent chains. The basic POM (one class per page, locators and methods) is the right level for:
- Projects with 20–50 tests
- Teams new to POM who are still learning the pattern
- Apps with a simple, mostly flat navigation structure
Add complexity only when the basic pattern is causing real pain: copy-pasted code that breaks in multiple places, difficulty finding where selectors are defined, tests that are hard to read because the page relationship isn't clear.
The goal is always readable, maintainable tests, not architectural sophistication.
→ See also: Page Object Model in Playwright: From Messy to Maintainable | TypeScript Interfaces and Types for Page Object Model | Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose