You probably don't need a Page Object Model
POM was a Selenium-era solution to a Selenium-era problem. In modern Cypress and Playwright, custom commands and locator helpers cover 90% of what POM was supposed to give you — with less ceremony.
What POM actually solved (1998, Java, brittle XPath selectors)
Page Object Model was formalised around 2010 by Simon Stewart and the Selenium team, but the pattern emerged earlier from WebDriver-style Java test suites. It solved a real problem: XPath selectors were brittle, they were duplicated everywhere, and when the DOM changed, a hundred tests broke.
The answer was encapsulation. Put all the selectors for a page into one class. Tests call methods on the class instead of writing raw XPath. When the DOM changes, you fix the class, and all tests using that class automatically pick up the fix.
// Classic Selenium POM
public class LoginPage {
private WebDriver driver;
@FindBy(id = "email")
private WebElement emailInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(css = ".submit-button")
private WebElement submitButton;
public void login(String email, String password) {
emailInput.sendKeys(email);
passwordInput.sendKeys(password);
submitButton.click();
}
}In 2010 Java Selenium, this was a genuine improvement. You had one place for selectors, you got type safety, and tests read like English sentences.
What modern frameworks already give you
Two things changed. First, teams started adopting data-testid attributes — stable, semantically clear selectors that don't break when designers refactor class names. Second, frameworks like Cypress and Playwright built retry-ability and async handling into the query layer itself, so the raw query is already reliable.
When your selectors are [data-testid="login-email"] and they don't change unless the component contract changes, the central repository argument for POM loses most of its force. The selector is stable by design.
Cypress's custom commands cover the other half — reusable interaction sequences:
// All the POM value, none of the ceremony
Cypress.Commands.add('loginViaUI', (email: string, password: string) => {
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="submit-button"]').click();
cy.get('[data-testid="user-avatar"]').should('be.visible');
});That's it. One command, no class hierarchy, no constructor, no this.driver. Tests call cy.loginViaUI(email, pass) and they're done.
The two cases where POM is still worth it
I said "probably don't need" not "never need." Two situations genuinely benefit from a page object.
Massive multi-step forms. If you have a checkout flow with eight steps, each collecting different data, a page object that encapsulates each step as a method is genuinely useful. The methods read naturally (checkout.fillShippingAddress(addr)), the test describes what the user is doing, and the selector details are hidden away.
Deeply nested workflows with shared state. Some workflows carry state across pages — a multi-page wizard where a value entered on step 2 affects what appears on step 4. If multiple test scenarios exercise this workflow, a page object that maintains that context and exposes it through methods can prevent duplication that would otherwise spread across dozens of tests.
In both cases, reach for POM because your specific workflow is complex — not because that's what tests are "supposed to" look like.
The pattern I use instead
For everything that falls short of those two cases, I use locator helpers paired with page-specific commands.
A locator helper is just an object of cy.get() calls, grouped by page. Not a class — a plain object:
// cypress/locators/settings.ts
export const settingsLocators = {
profileTab: () => cy.get('[data-testid="profile-tab"]'),
displayNameInput: () => cy.get('[data-testid="display-name-input"]'),
saveButton: () => cy.get('[data-testid="save-profile-button"]'),
successBanner: () => cy.get('[data-testid="save-success"]'),
};And page-specific custom commands for multi-step interactions:
// cypress/support/commands/settings.ts
Cypress.Commands.add('updateDisplayName', (name: string) => {
settingsLocators.profileTab().click();
settingsLocators.displayNameInput().clear().type(name);
settingsLocators.saveButton().click();
settingsLocators.successBanner().should('be.visible');
});Tests stay readable, selectors are centralised in a locator file (not a class), and there's no constructor or method binding to worry about. If a selector changes, you fix one line in the locator file.
"But our team is used to POM" — a pragmatic migration path
If your team has an existing POM setup and it's working, don't rewrite it. POM isn't wrong — it's just more overhead than you need in most cases.
What I'd suggest instead is drawing a boundary: keep existing page objects in place, but stop adding new ones. For new test files, use locator helpers and custom commands. Let the two approaches coexist.
Over time, if a page object becomes painful to maintain — tests failing because the class is stale, new engineers confused by the inheritance hierarchy — migrate it then. Pragmatic incrementalism beats a rewrite that breaks everything for three weeks.
The goal isn't ideological purity. It's tests that are easy to write, easy to read, and easy to fix when they break. The pattern that gets you there fastest is the right one for your team.
// related
Custom Cypress commands that actually pay off
Most teams over-abstract too early. Four custom commands are worth writing on every Cypress project — login, seed, intercept, visit. The rest can wait.
data-testid isn't a test smell. Brittle tests are.
There's a take going around that data-testid 'couples tests to implementation.' It's exactly backwards — data-testid is the only selector explicitly decoupled from implementation.