The honeycomb shape for a Next.js SaaS
The testing honeycomb, an integration-centered way to shape a Next.js SaaS test suite around the seams where it actually breaks.
The runner is green on an empty suite, and the first hundred tests the team writes now decide whether it earns its keep or quietly rots. The tempting move is to open a “testing best practices” article, find the test pyramid, and crank out unit tests because the diagram says the base should be wide. Six months later you have four hundred green tests, a production bug none of them caught, and a team that merges through a red suite because the suite is usually wrong about what matters.
To avoid that, stop asking “what’s the right test shape” and start asking “where does this kind of system actually break”, then let the answer pick the shape. That reframe is the whole lesson: shape follows the bug. It’s the discipline from the Row-Level Security chapter, where a database-level control waits for a real reason rather than guarding every table, now applied to the whole suite.
The four bug layers of a Next.js SaaS
Section titled “The four bug layers of a Next.js SaaS”Forget testing for a moment. Where can a bug actually hide in a Next.js 16 SaaS like the one you’ve been building? There are four places, and you’ve already written code in every one.
The first is pure logic in /lib: your Zod validators, data mappers, the RFC 9457 error-code mapper, the redactor that strips secrets from logs, the Temporal codecs that turn an Instant into a database string and back. This code is deterministic, the same input always gives the same output, with no database, network, or framework involved. A bug here is a wrong return value.
The second is the seams , where your code stops being a pure function and starts talking to the outside world: Server Actions wrapped in authedAction, route handlers wrapped in authedRoute, webhook receivers, Drizzle query helpers, the rate limiter safeLimit. A bug here isn’t a wrong number; it’s a query that returns another tenant’s data, or a webhook that trusts an unsigned body.
The third is components, the presentational and interactive UI the framework mostly owns. A bug here is a button that doesn’t disable while submitting, or a date picker that returns the wrong range.
The fourth is the end-to-end money paths: sign-in, Stripe Checkout, accepting an invitation. These aren’t single functions but multi-step flows that cross the whole stack, and a break here costs real money rather than returning a wrong value.
Two of these names carry the rest of the unit: the /lib surface and the seams. Given these four layers, where do you concentrate your tests?
Why the test pyramid misleads for a Next.js SaaS
Section titled “Why the test pyramid misleads for a Next.js SaaS”The most-repeated piece of testing advice is the test pyramid: many unit tests at the wide base, fewer integration tests in the middle, a thin cap of end-to-end tests on top. You’ll meet this diagram everywhere, so it earns a fair hearing.
The pyramid is correct, but only for the right system. Picture a banking back-end, a billing engine, or a physics simulation: software whose behavior is mostly computation you own, deep and intricate and framework-independent, like interest accrual, tax rules, or a pricing model with forty edge cases. There the bugs live inside the logic, the logic is pure, and pure logic is what unit tests are best at. Hundreds of fast unit tests at the base catch most of the bugs, because most of the system is unit-testable.
A Next.js SaaS is not that system. Walk through one of your features, say creating an invoice. The business logic you wrote is a Zod schema to validate the form, a mapper to shape the row, a Drizzle insert, and a cache tag to bust: a validator, a mapper, a query, a write. Everything else that feels like the app, rendering the page, routing the request, plumbing the Server Action, caching and streaming the result, you didn’t write. The framework owns the orchestration. Your application’s depth lives at the boundaries, where your thin slices of logic meet Postgres, Better Auth, Stripe, and Resend, not in a logic core that barely exists.
Apply the pyramid here anyway. You make the base wide, and a developer ends up writing this:
it('formats 1000 cents as $10.00', () => { expect(formatMoney(1000)).toBe('$10.00');});
it('formats 2500 cents as $25.00', () => { expect(formatMoney(2500)).toBe('$25.00');});
it('formats 0 cents as $0.00', () => { expect(formatMoney(0)).toBe('$0.00');});Ten variations of formatMoney, two hundred and fifty green tests, ninety percent line coverage. And shipped that same week: a Drizzle query that forgot its orgId filter, quietly serving one customer’s invoices to another. The pyramid aimed every effort at the base while the bug sat at a seam the base never touches.
Your suite’s shape should track your bug density. The pyramid encodes a deep-logic bug density; this architecture’s is boundary-heavy. Use the wrong map and you do real work that finds nothing.
A team has 250 passing unit tests and 90% line coverage. This week a webhook receiver shipped that calls JSON.parse on the request body before it checks the HMAC signature — and it sailed into production unnoticed. What does this most likely tell you?
Why the honeycomb fits this stack
Section titled “Why the honeycomb fits this stack”If the pyramid is the wrong shape, what’s the right one? It has a name, and it comes from systems that look like yours.
In 2018 Spotify’s engineering team published the testing honeycomb, for microservices whose main job is talking to other systems. Its center of gravity sits not at the base but in the middle: integration tests are the widest band, with thin layers above and below. The reasoning is the one we just walked through. When most of your behavior is interaction with external systems, the highest-value tests are the ones that exercise those interactions.
One honest caveat. Spotify’s figure is a three-band hexagon drawn for microservices; this course teaches a four-band adaptation for a Next.js SaaS, unit, integration, component, and E2E, because a SaaS has a real UI layer that a backend microservice doesn’t. What we borrow is the center of gravity, not the exact silhouette.
A 2026 Next.js SaaS fits the mold cleanly. Server Components and Server Actions are orchestrated by the framework, not by you; the database is external Postgres reached through Drizzle; Stripe, Resend, and auth all sit at boundaries. Almost everything interesting your app does is an interaction with one of these, so the tests worth writing live at the seams.
Place the honeycomb next to its two neighbors, because you’ll hear all three names. The pyramid you already know: right for a deep, framework-independent logic core, wrong here.
The testing trophy, from Kent C. Dodds, carries the slogan “write tests, not too many, mostly integration.” That last word matters: the trophy agrees with the honeycomb that integration is the center of gravity. A common misreading is that the trophy is “the one with a fat component layer”; it isn’t, its emphasis is integration too. What sets it apart is a visible static base of TypeScript types and the linter, plus a framing aimed at the JavaScript front end. The course pins to the honeycomb name because this SaaS’s logic and seams live on the server, where the honeycomb’s microservice heritage fits more snugly than the trophy’s client-app framing.
One guardrail, because it’s the most common way people misread the honeycomb. The shape names where tests live, not how many of each. It’s a location heuristic, not a quota. A year-one SaaS might ship two hundred unit tests, eighty integration tests, zero component tests, and four end-to-end tests and still be a perfect honeycomb, because the band weights follow the codebase, not a target on a chart. By year three, the integration count might overtake the unit count as the seam surface grows. The shape tells you which layer earns a given test; how many each layer ends up with is the question the next lesson takes apart under the name “coverage.”
What to test in each band
Section titled “What to test in each band”Now map the shape onto your codebase: for each band, which artifacts belong there.
Unit, the wide base. Every file in /lib ships a test: your Zod schemas, the RFC 9457 error-code mapper, the Temporal codecs, the redactor, every pure data transform. Add the type-level tests from the start of the course: a narrowing that has to hold, a branded ID that mustn’t accept a raw string, a discriminated union that must stay exhaustive. These tests are cheap to write and run, and need no fixtures, database, or mocks. The depth of this band, factories, determinism, and the unhappy path, is the next chapter’s job.
Integration, the center of gravity. This is where the honeycomb spends its weight, and you already have its catalog. In the chapter on fail-closed error discipline you walked six seams where your code meets the outside world, and that list is your integration-test list:
authedAction, the Server Action wrapper.authedRoute, the route-handler wrapper.requireOrgUser, the page-level access gate.- The webhook receiver.
safeLimit, the rate limiter.- The
error.tsxboundaries.
Each seam earns coverage on the two branches that decide its behavior: its fail-closed branch (does it refuse when it should?) and its message-split branch (does it return the right thing on each side of the decision?). On top of the six, every Drizzle query helper gets tested against a real test Postgres, with each test’s writes rolled back so the database stays clean, and any outbound HTTP call gets stubbed at the network boundary. The chapter on integration testing supplies the real-DB lifecycle and the network stubbing.
One claim in this band trips up almost everyone, so make it concrete:
export const archiveInvoice = authedAction(async ({ orgId, input }) => { const { id } = archiveInvoiceSchema.parse(input); const invoice = await db.transaction(async (tx) => { const row = await archiveInvoiceRow(tx, { id, orgId }); await logAudit(tx, { event: 'invoice.archived', invoiceId: id }); return row; }); return ok(invoice);});The three highlighted lines each cross a different boundary: the Zod .parse validates untrusted input, the Drizzle helper writes to Postgres, and the audit insert records the event. None of this is pure or unit-testable in isolation, because there’s no standalone function to call. Testing the action is testing the seam: the test reads a session, parses the input, hits the database, and checks the audit row. If a non-trivial chunk of logic hides inside, extract it into a pure function and unit-test that, but the action still needs its seam test. Webhook receivers are the same story: integration tests, never unit tests.
Component, thin. A component earns a test only when a named trigger is met; the triggers and React Testing Library come in the chapter on component testing. Until then, treat this as a conditional band, never the default.
End-to-end, thinner. This band covers the handful of paths where failure costs real money: sign-in, Checkout, invitation accept, your primary value loop. Playwright and its trigger come later. The convention to hold now: by year one you ship zero or four end-to-end tests, nothing in between. A half-built E2E suite flakes, and a flaky suite teaches the team to ignore red, destroying the signal you built it for. Either cover the money paths properly or don’t start.
Sort each piece of the SaaS into the layer that earns its test, and watch for the bucket most people forget exists.
Sort each piece of the SaaS into the test layer that earns its test — and notice that one bucket is for things that earn no test at all. Drag each item into the bucket it belongs to, then press Check.
.refine() ruleInstant → string codecauthedAction returning 403 when the role is below adminorgIdsafeLimit failing open on a Redis-auth error<Card> with no staterequireOrgUser() and renders a listThe “No test” bucket is the one beginners get wrong; it gets its own section later. First, why the integration band is so wide.
What unit tests can’t catch
Section titled “What unit tests can’t catch”The integration band is wide because the most dangerous bugs in a Next.js SaaS live at the seams. Here are the canonical ones, each drawn from code you’ve already written, each a production incident waiting to happen:
- The cross-tenant query that forgot its
orgIdfilter, serving one customer’s data to another. - The Server Action that skipped
authedActionentirely: no session check, no role check, wide open. - The webhook receiver that parsed the body as JSON before verifying the HMAC signature, trusting input it hadn’t authenticated.
- The cache tag that didn’t match its read tag, so a write left stale data on the screen.
- The rate limiter that swallowed a Redis throw and let the request proceed when it should have failed closed.
Not one shows up in a unit test, because none has a pure function to call. Each surfaces only when you run the real code path against a real test database with a real auth fixture: an integration test. The honeycomb’s wide middle sits directly over where these bugs land.
Cost is the second axis. Each band costs a different amount to write and run, and the honeycomb catches the most bugs per unit of effort for this codebase’s bug distribution.
The numbers behind the intuition: a unit test takes milliseconds to write and run, no fixtures. An integration test takes minutes to write (fixtures, a database, network calls to stub) and tens of milliseconds to run against the real DB. A component test takes minutes to write (DOM queries, async events) and hundreds of milliseconds to run under jsdom. An end-to-end test takes tens of minutes to write, runs in seconds with real browser overhead, and brings flake risk. The honeycomb loads the moderate-cost band with the highest bug yield and keeps the end-to-end band thin, reserved for paths where a missed bug costs lost revenue rather than a stack trace.
The next lesson reads coverage as a diagnostic, not a target.
When to add component and E2E tests
Section titled “When to add component and E2E tests”“Thin” doesn’t mean “occasionally, on a hunch.” It means conditional: off by default, switched on only by a named trigger, the same discipline as the Row-Level Security chapter. The default is that the unit or integration test already covers the logic under any piece of UI or any flow, so a component or E2E test has to earn its place against that default.
A component test is earned by one of three triggers: a piece from your shared component library that many callers depend on, a component with genuinely complex internal state, or a critical UX path where a silent break is unacceptable. Without one, the behavior is already covered at the seam or unit level, and a component test would only re-test what you’ve tested or re-test the framework.
An end-to-end test turns on a single question: does failure cost money? The bar is not “is this user-facing,” since almost everything is. It’s “would a silent break here lose revenue or lock users out.” Some 2026 web apps ship no end-to-end tests in year one and are right to: nothing in their early surface clears it yet.
Ask these questions in a fixed order: money first, triggers second, and only then where the behavior lives. That order cuts from the most expensive verdict down, which keeps you from over-testing. Work through it below for a piece of UI or a flow you have in mind.
Money paths earn the most expensive band, and the course keeps it thin: zero or four E2Es by year one, nothing in between. The trigger and Playwright itself arrive in the chapter on end-to-end testing.
A trigger is met, so this component earns a thin, deliberate test rather than a default one. The three triggers and RTL arrive in the chapter on component testing.
This is pure logic: the bug lives inside the function, where unit tests earn the most per line. This is the wide base, built out over /lib in the next chapter.
The behaviour is an interaction with an external system, so the seam is the thing under test: a db.transaction, an authedAction, a webhook receiver. This is the center of gravity, built seam by seam in the chapter on integration testing.
Presentational, with no behaviour to assert. A code review catches a broken <Card>; an automated test here would only re-test the framework you didn’t write.
What does not get a test
Section titled “What does not get a test”One verdict trips up beginners more than any other: no test. Over-testing fails as surely as under-testing: it burns time, slows the suite, and produces tests that break on every refactor without catching a bug. Two categories earn no automated test.
The first is the framework’s surface. Take a page that calls requireOrgUser() and renders a list. Routing, server rendering, and caching are Next.js’s job, and Vercel ships the Next.js test suite so you don’t have to. You test the data-fetching helper (unit or integration) and, if a trigger fires, the contract of what it renders (a component test), but never <Link>, <Image>, redirect(), notFound(), or App Router segment behavior. Your tests stop at the framework boundary; crossing it means re-testing code you didn’t write and can’t fix.
The second is UI plumbing with no behavior. A presentational component with no state, a <Card> that takes props and renders them, earns no test: there’s nothing to assert that a glance at the page wouldn’t catch. The narrow exception is a snapshot test, worth it only when it captures a contract a caller depends on: the HTML of an email template, or the exact shape of an RFC 9457 response body. Snapshot every <Card> and the suite demands a new snapshot every other PR, testing implementation rather than behavior, until the team updates snapshots blind.
Which lands the bar for the whole lesson: “we have tests” is not the bar. “Do the tests fail on the bugs that ship” is the bar. A green suite that misses every seam bug isn’t safety, it’s theatre, worse than no suite, because it manufactures confidence you haven’t earned.
External resources
Section titled “External resources”Martin Fowler's catalog of the pyramid, the honeycomb, and the trophy: the canonical reference for the shapes compared in this lesson.
Spotify Engineering's original honeycomb post: the source this lesson adapts, and the clearest argument for an integration-heavy center of gravity.
Kent C. Dodds on the trophy: the neighbour shape that also centres integration, plus his definitions of unit vs integration vs E2E.
web.dev compares pyramid, diamond, honeycomb, and trophy side by side and argues the shape should follow your architecture: the same thesis as this lesson.