How to test consent gates in a Next.js app with Playwright
Member of Technical Staff
Use Playwright to test that consent-controlled scripts, requests, cookies, and storage stay blocked before consent, load after consent, and stop after withdrawal in a Next.js app.
- Guides
- 8 min read
Consent bugs usually ship as timing bugs. A banner appears, the visitor has not chosen anything yet, and a marketing or analytics request has already left the browser.
That is the failure a unit test rarely catches. You need a browser test that watches what the page loads before consent, after consent, and after withdrawal.
This guide shows one way to test those consent gates in a Next.js App Router project with c15t and Playwright. The goal is not to prove that your whole privacy programme is correct. It is to catch the product behaviour that developers can verify in CI: scripts, requests, cookies, and storage should match the visitor's consent state.
What you will test
By the end, you can:
- Force a test environment into an opt-in consent flow.
- Record vendor requests with Playwright before any page code can hide them.
- Assert that consent-controlled vendors do not load before consent.
- Assert that the same vendors load after the matching category is granted.
- Check cookies and local storage as supporting evidence.
- Add a withdrawal test so consent does not only work on the accept path.
This guide assumes you already have c15t mounted in a Next.js App Router project. If you do not, start with the c15t Next.js quickstart.
Test the boundary, not the banner copy
For EU and UK traffic, the technical boundary is storage or access on the user's device. The EDPB's guidance on Article 5(3) of the ePrivacy Directive explains that the rule can apply beyond conventional cookies, including tracking pixels, tracking links, and instructions that cause a device to transmit information. UK PECR uses the same basic storage-or-access shape, with defined exceptions such as storage or access that is strictly necessary for a service requested by the user.
That changes the test design.
A useful test should not only ask whether the banner appears. It should ask whether a non-exempt analytics script, pixel, iframe, or request stayed blocked until the relevant choice existed. Deleting a cookie after a script has run is not the same control. The earlier request may already have accessed or transmitted data.
c15t is built around that boundary. When the provider mounts, it creates the consent runtime, reads stored consent, fetches or resolves policy data, applies gating rules, and blocks scripts, iframes, and network requests tagged with a consent category until that category is granted. When a user saves a choice, c15t updates the gating rules immediately. On revocation, c15t reloads the page by default to get back to a clean execution environment.
Your Playwright tests should follow the same lifecycle.
Add c15t in a testable shape
Keep vendor scripts inside the c15t provider. Do not render the same vendor through next/script, a layout <script>, or a tag manager rule that bypasses the consent runtime.
A simple provider might look like this:
The scripts above are placeholders. Replace them with your real vendors or built-in c15t helpers from @c15t/scripts where available. Built-in helpers are better for vendors with consent APIs, queue stubs, or specific boot order requirements.
The important part is ownership. c15t should own the script lifecycle for each consent-controlled vendor.
Configure Playwright for clean consent tests
Consent tests need isolated browser state. A decision saved in one test should not leak into the next one.
Playwright already creates an isolated browser context for each test by default. Keep that behaviour, and block service workers if you rely on request routing or interception. Playwright's docs note that service workers can hide requests from browserContext.route() and recommend serviceWorkers: 'block' when using routing.
Use a real opt-in policy in staging if you can. The override is only a test convenience. It makes the banner appear in development so the test has a stable consent model.
Record vendor requests before the page loads
Register network routing before page.goto(). If a script fires during initial render, the test should see it.
Create a small helper for the vendor domains your app expects to control:
Edit the pattern list for your app. The list should come from your vendor inventory, not from a copied blog post.
There is one important exception: Google Tag Manager and Google tags can be configured with denied-by-default consent signals. In c15t's script loader model, alwaysLoad is reserved for vendors that must be present early and have a reliable consent API. GTM with Consent Mode is the canonical example. If your app uses that model, do not expect a zero-request assertion for the GTM container itself. Test denied defaults, consent updates, and tag firing instead. The Consent Mode v2 guide covers that setup.
Assert that vendors do not load before consent
Now add the pre-consent test. It loads the page, waits for the consent UI, observes the browser for a short window, and fails if any controlled vendor request appears.
A fixed wait is not elegant, but it is honest for this kind of negative assertion. You are saying: during the first second after the banner became visible, no controlled vendor request was made. Tune the window to your app and keep it short enough for CI.
If the test fails, inspect the captured URL first. The usual causes are simple:
- The vendor still loads through next/script or a layout snippet.
- A tag manager rule fires outside the c15t lifecycle.
- A route-specific component imports a browser SDK that initialises at module load.
- The vendor belongs to the wrong consent category.
- The request is necessary for your product, but the inventory still labels it as analytics or marketing.
The last case needs review, not a test workaround.
Assert that vendors load after consent is granted
The second test checks the positive path. After the visitor grants the matching category, the expected request should appear.
Use a category-specific selector if your banner lets users accept only measurement. If your UI has an "accept all" path, this test proves less because marketing and measurement change at the same time. That may be enough for a smoke test. It is not enough for a consent-category regression suite.
For category-level coverage, open the preference dialog, enable one category, save, then assert that only the matching vendor loads.
Adjust the accessible names to your actual banner copy. Keep the selectors user-facing where possible. A consent test should fail if a visitor can no longer find the control.
Add storage checks as supporting evidence
Network assertions catch the earliest failure. Cookie and local storage checks catch the residue.
Then use those helpers after the pre-consent observation window:
Treat these as secondary checks. Third-party cookies may be blocked by the browser, scoped to another domain, marked httpOnly, or replaced by server-side identifiers. A clean cookie list does not prove the vendor never ran. A captured pre-consent request is stronger evidence.
Test withdrawal, not only acceptance
A consent flow is not finished when the accept button works. GDPR consent rules also require withdrawal to be as easy as giving consent, and users must be told about withdrawal before consenting. Product tests cannot decide whether your legal copy is enough, but they can verify that a withdrawal path exists and that future tracking stops.
c15t reloads the page by default when a granted category is revoked. That behaviour gives third-party code less room to keep running in memory after the user changes their mind.
The exact reopen control depends on your app. Many teams place a "Manage privacy preferences" link in the footer or account settings. If your site does not expose a durable preference control, the test failure is useful. The visitor needs a way back to the choice.
Keep the expected vendors in code
Do not scatter domain patterns across test files. Put the expected consent behaviour in a small inventory and use it in tests.
That inventory gives reviewers something concrete to inspect in pull requests. When a new vendor appears, the author has to say which category controls it and whether it is allowed before consent.
You can then generate a pre-consent test from the inventory:
This is the same habit as a vendor inventory, but narrowed to runtime tests. If you already maintain an inventory for consent categories, generate the test inventory from the same source.
What not to assert
Avoid assertions that look precise but prove the wrong thing.
Do not assert only that the banner exists. A banner can render while scripts fire behind it.
Do not assert only that cookies are absent. The browser may hide third-party cookies, and a pixel request can transmit data without leaving a readable cookie on your origin.
Do not treat every first-party endpoint as necessary. A first-party analytics proxy can still perform non-essential tracking.
Do not treat every third-party request as a violation. Fonts, security tooling, payments, fraud prevention, maps, and support widgets need their own purpose analysis. Some may be necessary for a requested service. Some may not.
Do not use production-only selectors that change with marketing copy if the control itself has a stable accessible name. If the button text changes from "Accept all" to "Allow all", the user-facing selector should change with it. If the test becomes brittle, fix the UI semantics instead of hiding the control behind a data-testid too early.
Sources to review before release
Use official sources for the legal and technical claims in this guide:
- EDPB guidelines on the technical scope of Article 5(3) of the ePrivacy Directive
- Regulation (EU) 2016/679, General Data Protection Regulation
- UK Privacy and Electronic Communications Regulations 2003
- c15t Next.js quickstart
- c15t initialization flow
- c15t script loader
- c15t consent categories
- Playwright network documentation
- Playwright BrowserContext documentation
What to do next
Start with one pre-consent test for your highest-risk vendor. For most teams, that is an advertising pixel, product analytics SDK, or tag manager container.
Once that test passes, add the positive path and withdrawal path. Then move the vendor patterns into an inventory so future SDKs cannot slip into the app without a category and a test.
If your stack uses PostHog, Meta Pixel, or Google tools, use the vendor-specific guides next: PostHog with GDPR-aware consent, Meta Pixel with c15t, and Consent Mode v2 as a product control. Each vendor has different runtime behaviour. Your tests should make that behaviour visible.