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.

In this article
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.jsonThe 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
- One page class per logical page — don't combine multiple pages.
- Use
getByRole,getByTestId,getByLabel— avoid CSS/XPath. - Keep methods user-focused —
loginAs, notfillEmail + fillPassword + clickSubmit. - Return new page objects from action methods — for navigation flows.
- Use
waitForLoaded()after navigation — auto-wait isn't enough for complex apps. - Component POM for shared UI — header, footer, modal.
- Use fixtures for cleaner tests — extend
testwith your page objects. - 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.
Practice these questions
Drill 200+ Playwright questions with senior-SDET sample answers — locators, auto-wait, fixtures, parallelism and trace viewer.
Was this article helpful?
Keep building your QA edge
Pillar guidesContinue reading

Why Every QA Engineer Must Master CI/CD Pipelines in 2026 (Or Risk Obsolescence)
12 min read
Is Cypress Dead? Analyzing 2026 Playwright Market Share
12 min read
Why Tests Pass Locally But Fail in CI/CD (And the 6 Fixes That Actually Work in 2026)
13 min readJoin the QA Community
Connect with fellow testers, share job leads, and get career advice.
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