Page Object Model in Playwright with TypeScript: Complete Guide

Published: · 2 min read

Learn how to structure scalable Playwright tests using the Page Object Model pattern. Includes TypeScript examples, best practices, and real-world architecture patterns.

Page Object Model in Playwright with TypeScript - test architecture guide

What is the Page Object Model?

The Page Object Model (POM) is a design pattern that creates an abstraction layer between your tests and your web pages. Instead of writing locators directly in tests, you encapsulate page elements and actions inside reusable classes.

Without POM (hard to maintain):

test('user can login', async ({ page }) => {
  await page.goto('/login');
  await page.locator('#email').fill('user@example.com');
  await page.locator('#password').fill('password123');
  await page.locator('button[type="submit"]').click();
  await expect(page.locator('.dashboard-welcome')).toBeVisible();
});

With POM (scalable):

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(loginPage.welcomeMessage).toBeVisible();
});

When the login form changes, you update one file — not 50 tests.

Why Use Page Objects?

1. Maintainability

Locators live in one place. When UI changes, update once.

2. Readability

Tests read like user stories: loginPage.login(), dashboard.navigateToSettings().

3. Reusability

Login logic written once, used in 100 tests.

4. Separation of concerns

Tests focus on behavior. Page objects handle implementation.

5. Team scalability

New team members understand tests faster.

Project Structure

Here's the structure I use for all my Playwright projects:

tests/
├── pages/
│   ├── BasePage.ts
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── CheckoutPage.ts
├── components/
│   ├── Header.ts
│   ├── Modal.ts
│   └── Sidebar.ts
├── fixtures/
│   └── fixtures.ts
├── specs/
│   ├── login.spec.ts
│   ├── dashboard.spec.ts
│   └── checkout.spec.ts
└── playwright.config.ts

Creating a Base Page Class

Start with a base class that all pages extend:

// tests/pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
  protected page: Page;
  constructor(page: Page) {
    this.page = page;
  }
  async goto(): Promise<void> {
    await this.page.goto(this.url);
  }
  abstract get url(): string;
  // Common elements across all pages
  get header(): Locator {
    return this.page.getByRole('banner');
  }
  get footer(): Locator {
    return this.page.getByRole('contentinfo');
  }
  // Common actions
  async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState('networkidle');
  }
}

Creating a Login Page Object

Now create a specific page class:

// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }
  get url(): string {
    return '/login';
  }
  // Locators (use getters for lazy evaluation)
  get emailInput(): Locator {
    return this.page.getByLabel('Email address');
  }
  get passwordInput(): Locator {
    return this.page.getByLabel('Password');
  }
  get submitButton(): Locator {
    return this.page.getByRole('button', { name: 'Sign in' });
  }
  get errorMessage(): Locator {
    return this.page.getByRole('alert');
  }
  get forgotPasswordLink(): Locator {
    return this.page.getByRole('link', { name: 'Forgot password?' });
  }
  // Actions
  async login(email: string, password: string): Promise<void> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
  async loginAndExpectDashboard(email: string, password: string): Promise<void> {
    await this.login(email, password);
    await expect(this.page).toHaveURL(/.*dashboard/);
  }
  async loginWithInvalidCredentials(email: string, password: string): Promise<void> {
    await this.login(email, password);
    await expect(this.errorMessage).toBeVisible();
  }
}

Creating a Dashboard Page Object

// tests/pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }
  get url(): string {
    return '/dashboard';
  }
  // Locators
  get welcomeMessage(): Locator {
    return this.page.getByRole('heading', { name: /Welcome/ });
  }
  get userMenu(): Locator {
    return this.page.getByRole('button', { name: 'User menu' });
  }
  get logoutButton(): Locator {
    return this.page.getByRole('menuitem', { name: 'Logout' });
  }
  get settingsLink(): Locator {
    return this.page.getByRole('link', { name: 'Settings' });
  }
  // Actions
  async logout(): Promise<void> {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
  async navigateToSettings(): Promise<void> {
    await this.settingsLink.click();
  }
}

Using Page Objects in Tests

Now your tests are clean and readable:

// tests/specs/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test.describe('Login functionality', () => {
  test('user can login with valid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const dashboardPage = new DashboardPage(page);
    await loginPage.goto();
    await loginPage.login('valid@example.com', 'password123');
    await expect(dashboardPage.welcomeMessage).toBeVisible();
  });
  test('user sees error with invalid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.loginWithInvalidCredentials('invalid@example.com', 'wrong');
    await expect(loginPage.errorMessage).toContainText('Invalid credentials');
  });
  test('user can navigate to forgot password', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.forgotPasswordLink.click();
    await expect(page).toHaveURL(/.*forgot-password/);
  });
});

Advanced: Component Objects

For reusable UI components (modals, headers, sidebars), create component classes:

// tests/components/Modal.ts
import { Page, Locator } from '@playwright/test';
export class Modal {
  private page: Page;
  private container: Locator;
  constructor(page: Page) {
    this.page = page;
    this.container = page.getByRole('dialog');
  }
  get title(): Locator {
    return this.container.getByRole('heading');
  }
  get closeButton(): Locator {
    return this.container.getByRole('button', { name: 'Close' });
  }
  get confirmButton(): Locator {
    return this.container.getByRole('button', { name: 'Confirm' });
  }
  get cancelButton(): Locator {
    return this.container.getByRole('button', { name: 'Cancel' });
  }
  async close(): Promise<void> {
    await this.closeButton.click();
  }
  async confirm(): Promise<void> {
    await this.confirmButton.click();
  }
}

Use it in page objects:

// In DashboardPage.ts
async openDeleteModal(): Promise<Modal> {
  await this.deleteButton.click();
  return new Modal(this.page);
}

Advanced: Using Playwright Fixtures

For even cleaner tests, integrate page objects with Playwright fixtures:

// tests/fixtures/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
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/specs/login.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('user can login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

Best Practices

1. Use getters for locators

Getters are lazily evaluated. The locator is resolved when accessed, not when the class is instantiated.

// Good
get submitButton(): Locator {
  return this.page.getByRole('button', { name: 'Submit' });
}
// Avoid (evaluated too early)
submitButton = this.page.getByRole('button', { name: 'Submit' });

2. Keep actions atomic

Each method should do one logical thing:

// Good
async login(email: string, password: string) { ... }
async fillEmail(email: string) { ... }
// Avoid combining unrelated actions
async loginAndCheckSettings() { ... }

3. Don't add assertions in page objects (usually)

Assertions belong in tests. Page objects encapsulate interactions.

Exception: Verification methods like loginAndExpectDashboard() can include assertions for convenience.

4. Use role-based locators

More resilient than CSS selectors:

// Good
page.getByRole('button', { name: 'Submit' })
// Fragile
page.locator('#submit-btn-primary')

Common Mistakes to Avoid

1. God page objects

If your page object has 50+ methods, break it into smaller components.

2. Exposing raw locators everywhere

Encapsulate interactions: loginPage.login() instead of loginPage.submitButton.click().

3. Not waiting for navigation

After clicks that navigate, wait for the URL or element:

await this.submitButton.click();

await this.page.waitForURL(/.*dashboard/);

4. Sharing state between tests

Each test should be independent. Don't rely on previous test state.

Folder Structure for Large Projects

For 100+ tests, organize by feature:

tests/
├── pages/
├── components/
├── fixtures/
├── auth/
│   ├── login.spec.ts
│   ├── register.spec.ts
│   └── password-reset.spec.ts
├── checkout/
│   ├── cart.spec.ts
│   ├── payment.spec.ts
│   └── confirmation.spec.ts
└── admin/
    ├── users.spec.ts
    └── settings.spec.ts

Need Architecture Help?

If you're building a test automation framework and want it done right from the start, I can help design the architecture, set up page objects, and train your team on best practices.

Get in touch

Subscribe

Get notified when I publish something new, and unsubscribe at any time.

Latest articles

Read all my blog posts

· 2 min read

Page Object Model in Playwright with TypeScript: Complete Guide

Learn how to structure scalable Playwright tests using the Page Object Model pattern. Includes TypeScript examples, best practices, and real-world architecture patterns.

Page Object Model in Playwright with TypeScript: Complete Guide

· 4 min read

How to Migrate from Selenium to Playwright in 2026: Complete Guide

A practical, step-by-step guide to migrating your Selenium test suite to Playwright. Includes code comparison, common pitfalls, and a migration strategy that won't disrupt your team.

How to Migrate from Selenium to Playwright in 2026: Complete Guide