Most Playwright Page Object Model (POM) tutorials stop at "put your selectors in a class". That part is easy. The trouble starts three months later: page objects with 40 methods, waitForTimeout(2000) sprinkled everywhere, assertions hidden in helpers, and every test file starting with five lines of new SomethingPage(page).

This tutorial shows a POM structure in TypeScript that stays small and readable as the suite grows. All code runs against practicesoftwaretesting.com (the "Toolshop" demo app), so you can copy it and run it today.

You will build:

  • a tiny BasePage with navigation and a readiness check
  • page objects that use data-test locators and expose Locators, not strings
  • a page method that waits for the API response instead of sleeping
  • custom fixtures so tests receive ready-made page objects
  • tests that read like a checklist

1. Setup

npm init playwright@latest pom-demo -- --quiet --lang=ts cd pom-demo 

Then tell Playwright which attribute your app uses for test ids. Toolshop uses data-test, so getByTestId('email') will match data-test="email":

// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', fullyParallel: true, retries: process.env.CI ? 2 : 0, use: { baseURL: 'https://practicesoftwaretesting.com', testIdAttribute: 'data-test', trace: 'retain-on-failure', }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], }); 

Setting baseURL means page objects can call page.goto('/auth/login') and never hard-code a domain. Pointing the suite at staging later is a one-line change.

The folder layout we are aiming for:

src/ pages/ BasePage.ts LoginPage.ts AccountPage.ts CatalogPage.ts index.ts fixtures/ test.ts tests/ login.spec.ts catalog.spec.ts 

2. Four rules that keep page objects healthy

Before any code, these are the rules the rest of the article follows:

  1. A page object describes a page. It does not test it. It exposes elements and user actions (login(), search()). The test decides what "correct" means.
  2. Expose Locators, not selector strings. A Locator is lazy and auto-waits, so tests can use web-first assertions like await expect(loginPage.error).toBeVisible().
  3. Wait for a real signal, never for time. A visible element, a URL, or a network response. waitForTimeout is a bug you have not found yet.
  4. Keep the base class small. Navigation, shared header elements, a readiness check. Anything else belongs in the page that owns it.

The one exception to rule 1: a page object may use expect to wait until it is ready (for example, "the email field is visible"). That is synchronisation, not a business assertion.

3. The BasePage

// src/pages/BasePage.ts import { expect, type Locator, type Page } from '@playwright/test'; /** Shared behaviour for every page object: navigation, header, readiness. */ export abstract class BasePage { protected abstract readonly path: string; readonly accountMenu: Locator; readonly signInLink: Locator; readonly cartBadge: Locator; constructor(protected readonly page: Page) { this.accountMenu = page.getByTestId('nav-menu'); this.signInLink = page.getByTestId('nav-sign-in'); this.cartBadge = page.getByTestId('cart-quantity'); } async open(): Promise<this> { await this.page.goto(this.path); await this.waitUntilLoaded(); return this; } /** Override when a page needs a stronger readiness signal. */ async waitUntilLoaded(): Promise<void> { await expect(this.page.getByTestId('nav-home')).toBeAttached(); } } 

A few details worth copying:

  • path is abstract, so every page must declare its URL, and open() works for all of them.
  • open() returns this, so you can write const login = await loginPage.open() when it reads better.
  • waitUntilLoaded() is the hook that kills flakiness. Every page overrides it with the element that proves the page is usable.

4. A simple page: LoginPage

// src/pages/LoginPage.ts import { expect, type Locator, type Page } from '@playwright/test'; import { AccountPage } from './AccountPage'; import { BasePage } from './BasePage'; export type Credentials = { email: string; password: string }; export class LoginPage extends BasePage { protected readonly path = '/auth/login'; readonly email: Locator; readonly password: Locator; readonly submit: Locator; readonly error: Locator; constructor(page: Page) { super(page); this.email = page.getByTestId('email'); this.password = page.getByTestId('password'); this.submit = page.getByTestId('login-submit'); this.error = page.getByTestId('login-error'); } override async waitUntilLoaded(): Promise<void> { await expect(this.email).toBeVisible(); } async login({ email, password }: Credentials): Promise<void> { await this.email.fill(email); await this.password.fill(password); await this.submit.click(); } /** Fluent helper: returns the next page object on success. */ async loginAs(credentials: Credentials): Promise<AccountPage> { await this.login(credentials); const account = new AccountPage(this.page); await account.waitUntilLoaded(); return account; } } 

Notice there are two login methods:

  • login() just performs the action. Use it for negative tests, where you then assert on loginPage.error.
  • loginAs() performs the action and returns the next page once it is ready. Use it when login is a step on the way to something else.

That split avoids the classic login(email, password, expectSuccess = true) flag, which turns into a mess of if statements.

The account page it returns:

// src/pages/AccountPage.ts import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; export class AccountPage extends BasePage { protected readonly path = '/account'; readonly title: Locator; constructor(page: Page) { super(page); this.title = page.getByTestId('page-title'); } override async waitUntilLoaded(): Promise<void> { await expect(this.page).toHaveURL(/\/account$/); await expect(this.title).toHaveText('My account'); } } 

5. A harder page: wait for the API, not for time

The Toolshop home page is a product grid with a search box. Here is where most POMs go wrong: after clicking "Search" the old products are still on screen for a moment, so a test that reads the grid immediately gets stale results, passes locally and fails in CI.

The fix is to start listening for the search response before clicking, and then wait until the grid shows exactly what the API returned:

// src/pages/CatalogPage.ts import { expect, type Locator, type Page, type Response } from '@playwright/test'; import { BasePage } from './BasePage'; type Product = { id: string; name: string; price: number }; type Paginated<T> = { data: T[]; total: number }; export class CatalogPage extends BasePage { protected readonly path = '/'; readonly searchBox: Locator; readonly searchButton: Locator; readonly searchCaption: Locator; readonly sort: Locator; readonly productNames: Locator; readonly productPrices: Locator; constructor(page: Page) { super(page); this.searchBox = page.getByTestId('search-query'); this.searchButton = page.getByTestId('search-submit'); this.searchCaption = page.getByTestId('search-caption'); this.sort = page.getByTestId('sort'); this.productNames = page.getByTestId('product-name'); this.productPrices = page.getByTestId('product-price'); } override async waitUntilLoaded(): Promise<void> { // Cards render before their data arrives, so wait for a name with real text. await expect(this.productNames.filter({ hasText: /\S/ }).first()).toBeVisible(); } /** Searches and waits until the grid shows exactly what the API returned. */ async search(term: string): Promise<Paginated<Product>> { const response = this.page.waitForResponse((r) => r.url().includes('/products/search') && r.ok()); await this.searchBox.fill(term); await this.searchButton.click(); await expect(this.searchCaption).toContainText(term); return this.gridMatches(await response); } async sortBy(value: 'name,asc' | 'name,desc' | 'price,asc' | 'price,desc'): Promise<Paginated<Product>> { const response = this.page.waitForResponse( (r) => /\/products(\?|$)/.test(r.url()) && (r.url() + (r.request().postData() ?? '')).includes(value) && r.ok(), ); await this.sort.selectOption(value); return this.gridMatches(await response); } private async gridMatches(response: Response): Promise<Paginated<Product>> { const body = (await response.json()) as Paginated<Product>; await expect(this.productNames).toHaveText(body.data.map((p) => p.name)); return body; } async prices(): Promise<number[]> { const texts = await this.productPrices.allInnerTexts(); return texts.map((t) => Number(t.replace(/[^0-9.]/g, ''))); } } 

Three things make this reliable:

  1. waitForResponse is created before the click. If you create it after, a fast response can arrive before you start listening and the test hangs until timeout.
  2. toHaveText([...names]) with an array is a web-first assertion. It retries until the grid shows exactly those names in that order, so you never read a half-updated list.
  3. search() returns the API payload. Tests can compare UI and API data without making a second request.

And a barrel file so imports stay short:

// src/pages/index.ts export * from './BasePage'; export * from './LoginPage'; export * from './AccountPage'; export * from './CatalogPage'; 

6. Fixtures: stop writing new LoginPage(page)

If every test begins with const loginPage = new LoginPage(page), you are doing work Playwright can do for you. Extend the base test with fixtures:

// src/fixtures/test.ts import { test as base, expect } from '@playwright/test'; import { AccountPage, CatalogPage, LoginPage } from '../pages'; type Pages = { loginPage: LoginPage; accountPage: AccountPage; catalogPage: CatalogPage; }; export const test = base.extend<Pages>({ loginPage: async ({ page }, use) => use(new LoginPage(page)), accountPage: async ({ page }, use) => use(new AccountPage(page)), catalogPage: async ({ page }, use) => use(new CatalogPage(page)), }); export { expect }; 

Fixtures are lazy: a test that only asks for catalogPage never creates the others. They also give you one place to add setup and teardown later (an API client, a fresh test user, a cart that is deleted after the test).

7. The tests

Toolshop publishes demo credentials on its login page (customer@practicesoftwaretesting.com / welcome01). In a real project, read them from environment variables.

// tests/login.spec.ts import { test, expect } from '../src/fixtures/test'; const customer = { email: process.env.CUSTOMER_EMAIL ?? 'customer@practicesoftwaretesting.com', password: process.env.CUSTOMER_PASSWORD ?? 'welcome01', }; test.describe('Login', () => { test.beforeEach(async ({ loginPage }) => { await loginPage.open(); }); test('customer can sign in and lands on their account', async ({ loginPage }) => { const account = await loginPage.loginAs(customer); await expect(account.title).toHaveText('My account'); await expect(account.accountMenu).toBeVisible(); }); test('wrong password shows an error and stays on the login page', async ({ loginPage, page }) => { await loginPage.login({ email: customer.email, password: 'definitely-wrong-1!' }); await expect(loginPage.error).toHaveText('Invalid email or password'); await expect(page).toHaveURL(/\/auth\/login$/); }); }); 
// tests/catalog.spec.ts import { test, expect } from '../src/fixtures/test'; test.describe('Catalog', () => { test.beforeEach(async ({ catalogPage }) => { await catalogPage.open(); }); for (const term of ['pliers', 'hammer']) { test(`search "${term}" shows only matching products`, async ({ catalogPage }) => { const result = await catalogPage.search(term); expect(result.data.length).toBeGreaterThan(0); for (const name of await catalogPage.productNames.allInnerTexts()) { expect(name.toLowerCase()).toContain(term); } }); } test('sort by price, high to low', async ({ catalogPage }) => { await catalogPage.sortBy('price,desc'); const prices = await catalogPage...
Source:https://dev.to/goldishqa/playwright-page-object-model-in-typescript-the-right-way-2b2p