SoftwareTestPilot
Automation TestingPublished: 8 min read

Playwright Page Object Model in TypeScript (2026 Guide)

Step-by-step guide to Playwright Page Object Model in TypeScript with real examples. Base page, page classes, fixtures, component POM, and best practices for 2026.

Avinash Kamble
Avinash Kamble
Founder & QA Engineer at SoftwareTestPilot
Reviewed by Priyanka G.
Share:XLinkedInWhatsApp
Playwright Page Object Model in TypeScript cover — base page, page classes, component POM, and fixtures for scalable test automation.
Playwright Page Object Model in TypeScript cover — base page, page classes, component POM, and fixtures for scalable test automation.
In this article
  1. What is the Page Object Model?
  2. Project Structure
  3. The Base Page
  4. The Login Page
  5. The Dashboard Page
  6. The Test
  7. Component POM (Recommended for 2026)
  8. Using Fixtures for Cleaner Tests
  9. Best Practices for 2026
  10. Anti-Patterns to Avoid
  11. Keep Learning
  12. Frequently asked questions

The Page Object Model (POM) is the most important pattern for any non-trivial Playwright project. This guide walks you through the full TypeScript POM pattern with real examples — base page, page classes, component POM, and fixtures. If you're new to Playwright, start with our Playwright complete guide, then come back here to design a framework that scales.

What is the Page Object Model?

POM is a design pattern where each page (or component) is a class with locators as properties and actions as methods. Tests interact with page objects, never raw locators.

Benefits:

  • One place to update locators when the UI changes
  • Tests read like user stories, not scripts
  • Parallel authoring of tests and locators
  • Easier code review

For the broader Playwright context, see our Playwright Complete Guide.

Project Structure

playwright-pom/
├── tests/
│   ├── login.spec.ts
│   └── checkout.spec.ts
├── pages/
│   ├── base-page.ts
│   ├── login-page.ts
│   ├── dashboard-page.ts
│   └── checkout-page.ts
├── components/
│   ├── header.ts
│   └── footer.ts
├── fixtures/
│   └── test-fixtures.ts
├── playwright.config.ts
└── package.json

The Base Page

Every page object extends a base class with shared behavior:

// pages/base-page.ts
import { Page, Locator, expect } from '@playwright/test';

export abstract class BasePage {
  constructor(protected page: Page) {}

  abstract get url(): string;

  async goto(): Promise<void> {
    await this.page.goto(this.url);
    await this.waitForLoaded();
  }

  abstract waitForLoaded(): Promise<void>;

  async getTitle(): Promise<string> {
    return await this.page.title();
  }
}

The Login Page

// pages/login-page.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './base-page';
import { DashboardPage } from './dashboard-page';

export class LoginPage extends BasePage {
  get url(): string {
    return '/login';
  }

  private get emailField(): Locator {
    return this.page.getByTestId('email');
  }

  private get passwordField(): Locator {
    return this.page.getByTestId('password');
  }

  private get submitButton(): Locator {
    return this.page.getByTestId('submit');
  }

  private get errorMessage(): Locator {
    return this.page.getByTestId('error-message');
  }

  async waitForLoaded(): Promise<void> {
    await this.emailField.waitFor({ state: 'visible' });
  }

  async loginAs(email: string, password: string): Promise<DashboardPage> {
    await this.emailField.fill(email);
    await this.passwordField.fill(password);
    await this.submitButton.click();
    return new DashboardPage(this.page);
  }

  async getErrorText(): Promise<string> {
    return (await this.errorMessage.textContent()) ?? '';
  }
}

The Dashboard Page

// pages/dashboard-page.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './base-page';

export class DashboardPage extends BasePage {
  get url(): string {
    return '/dashboard';
  }

  private get heading(): Locator {
    return this.page.getByRole('heading', { name: /welcome/i });
  }

  private get logoutButton(): Locator {
    return this.page.getByTestId('logout');
  }

  async waitForLoaded(): Promise<void> {
    await this.page.waitForURL(/\/dashboard$/);
  }

  async getWelcomeMessage(): Promise<string> {
    return (await this.heading.textContent()) ?? '';
  }

  async logout(): Promise<void> {
    await this.logoutButton.click();
  }
}

The Test

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test.describe('Login flow', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();

    const dashboard = await login.loginAs('admin@example.com', 'Sup3rSecret!');
    await dashboard.waitForLoaded();

    expect(await dashboard.getWelcomeMessage()).toContain('Welcome');
  });

  test('invalid credentials shows error', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    await login.loginAs('admin@example.com', 'wrong-password');

    expect(await login.getErrorText()).toContain('Invalid credentials');
  });
});

The test reads like a user story — much easier to maintain than raw locators.

Component POM (Recommended for 2026)

Modern apps have reusable components (header, footer, modal). Component POM is the same pattern, scoped to a component:

// components/header.ts
import { Page, Locator } from '@playwright/test';

export class Header {
  constructor(private page: Page) {}

  private get logo(): Locator {
    return this.page.getByTestId('logo');
  }

  private get navLinks(): Locator {
    return this.page.getByRole('navigation').getByRole('link');
  }

  async clickLogo(): Promise<void> {
    await this.logo.click();
  }

  async clickNavLink(name: string): Promise<void> {
    await this.navLinks.filter({ hasText: name }).click();
  }

  async getNavLinkCount(): Promise<number> {
    return await this.navLinks.count();
  }
}

Use it in a page:

import { Header } from '../components/header';

export class DashboardPage extends BasePage {
  readonly header: Header;

  constructor(page: Page) {
    super(page);
    this.header = new Header(page);
  }

  async clickNavLink(name: string): Promise<void> {
    await this.header.clickNavLink(name);
  }
}

Using Fixtures for Cleaner Tests

Playwright fixtures let you inject page objects into tests:

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

type MyFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect } from '@playwright/test';

Now tests are even cleaner:

// tests/login.spec.ts
import { test, expect } from '../fixtures/test-fixtures';

test('successful login', async ({ loginPage, page }) => {
  await loginPage.goto();
  const dashboard = await loginPage.loginAs('admin@example.com', 'Sup3rSecret!');
  expect(await dashboard.getWelcomeMessage()).toContain('Welcome');
});

The official Playwright fixtures docs cover advanced patterns like worker-scoped fixtures and auto-fixtures.

Best Practices for 2026

  1. One page class per logical page — don't combine multiple pages.
  2. Use getByRole, getByTestId, getByLabel — avoid CSS/XPath.
  3. Keep methods user-focusedloginAs, not fillEmail + fillPassword + clickSubmit.
  4. Return new page objects from action methods — for navigation flows.
  5. Use waitForLoaded() after navigation — auto-wait isn't enough for complex apps.
  6. Component POM for shared UI — header, footer, modal.
  7. Use fixtures for cleaner tests — extend test with your page objects.
  8. Avoid hard-coded URLs in tests — use goto() from the page object.

Anti-Patterns to Avoid

❌ Locators in tests

// BAD
test('login', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByTestId('email').fill('admin@example.com');
  await page.getByTestId('submit').click();
});

✅ POM everywhere

// GOOD
test('login', async ({ loginPage, page }) => {
  await loginPage.goto();
  await loginPage.loginAs('admin@example.com', 'Sup3rSecret!');
});

❌ Methods that return locators

// BAD — tests can use the locator and bypass POM
get emailField(): Locator { return this.page.getByTestId('email'); }

✅ Methods that perform actions

// GOOD — tests must use the action
async enterEmail(email: string): Promise<void> {
  await this.emailField.fill(email);
}

Keep Learning

Once your POM is solid, level up your suite with a complete framework setup in our Playwright framework with TypeScript guide, then prep for interviews using Playwright interview questions and our AI mock interview. If you're choosing between tools, compare options in Playwright vs Selenium.

Frequently asked questions

What is the Page Object Model in Playwright?

A design pattern where each page is a class with locators as properties and actions as methods. Tests interact with page objects, not raw locators — making suites easier to maintain when the UI changes.

Should I use POM in Playwright?

Yes — for any non-trivial app, POM makes tests maintainable and readable. Small throwaway scripts can skip it, but anything that runs in CI long-term benefits from the pattern.

What's the difference between POM and component POM?

Page POM represents a full page. Component POM represents a reusable UI component (header, modal, footer) that's shared across pages — composed inside page objects to avoid duplication.

How do I share page objects across tests?

Use Playwright fixtures (base.extend()) to inject page objects into your tests. Each test receives a fresh instance bound to its own page, so you avoid manual setup boilerplate.

Should I expose locators from page objects?

No — expose actions (methods) and queries. Tests should never need to know about locators. Returning raw locators lets tests bypass the abstraction and reintroduces the maintenance problem POM is meant to solve.

Keep going

Practice these questions

Drill 200+ Playwright questions with senior-SDET sample answers — locators, auto-wait, fixtures, parallelism and trace viewer.

Found this useful?
Share:XLinkedInWhatsApp

Was this article helpful?

Keep building your QA edge

Continue reading

Join the QA Community

Connect with fellow testers, share job leads, and get career advice.

Premium QA Resources

Stop Reinventing the Wheel. Upgrade Your QA Arsenal.

Take your testing skills from beginner to Lead Engineer. Supercharge your daily workflow with our premium digital resources.

  • ⚡ Ready-to-use testing strategy templates
  • 🔥 Advanced API & UI automation guides
  • ⏱️ Save 10+ hours a week on test planning
4.9/5 rating
Explore All Products

⭐⭐⭐⭐⭐ Trusted by 1,000+ Software Test Pilots • Instant Access