Skip to content

Vertical slice

A vertical slice is a feature shipped through every layer it touches - UI, data fetch, feature flag, analytics, persistence - in one narrow strip rather than building each layer broadly first and stitching later. For growth engineering, every test variant is a vertical slice whether you call it one or not.

The opposite is horizontal slicing. The team agrees to “do all the components first”, “then wire up the data”, “then add tracking”, “then put it behind a flag”. Each layer is broad. Nothing renders end-to-end until the last week. The CRO calendar slips into the next quarter.

A test is a unit of learning. Until the variant is rendered, tracked, flagged, and analysable, nothing has been learned. Horizontal slicing optimises for engineering ergonomics - you write all the React components in a row, all the API routes in a row, all the schemas at once. The work feels efficient. The slice that proves the hypothesis arrives months late, or not at all.

Vertical slicing accepts repetition. Five separate, similar-ish features will each implement their own thin version of the same layers, and the abstraction gets pulled out at the third or fourth occurrence. That feels wasteful and it isn’t. The cost of a wrong abstraction extracted from one feature is bigger than the cost of repeating yourself across three.

A worked example: headless Next.js + Shopify

Section titled “A worked example: headless Next.js + Shopify”

The hypothesis: showing a “Frequently bought together” module on PDPs lifts AOV. The team is on a Next.js storefront fronted by Shopify’s Storefront API.

The slice has to touch:

  1. A flag, so the module is only shown to the treatment group.
  2. A data fetch, because recommendations come from somewhere.
  3. A component, to render the products with price, image, and add-to-cart.
  4. Three events - module impression, item click, item add-to-cart.
  5. A mutation against Shopify’s cart.

The horizontal approach builds a Recommendations Service over three sprints, a generic ProductCard component, an instrumentation layer for “module-style” tracking. Nothing ships.

The vertical approach builds the thinnest version of each layer. About 150 lines of code across four files. Two days of work. Behind a flag, ready to flip to 50% of traffic.

app/products/[handle]/page.tsx
import { evaluateFlag } from '@/lib/experiments'
import { FrequentlyBought } from './FrequentlyBought'
export default async function ProductPage({ params }) {
const product = await getProduct(params.handle)
const variant = await evaluateFlag('pdp_fbt_v1', {
userId: await getUserId(),
})
return (
<>
<Product product={product} />
{variant === 'treatment' && (
<FrequentlyBought productId={product.id} variant={variant} />
)}
</>
)
}

The flag call happens on the server, which kills flicker - the HTML arrives with or without the module, no client-side swap. The variant value is passed into the module so events can be attributed correctly.

app/products/[handle]/FrequentlyBought.tsx
async function getFbtProducts(productId: string) {
const res = await shopifyFetch({
query: GET_FBT_PRODUCTS,
variables: { productId },
next: { tags: [`fbt:${productId}`], revalidate: 3600 },
})
return res.data.product.recommendations.slice(0, 3)
}

Cached for an hour, revalidated on demand. The first version doesn’t need a recommendation engine - Shopify’s productRecommendations query returns related products. Ship that, iterate later. If the slice proves the hypothesis, the v2 is “swap the data source for our own model”. The component, the flag, the events all stay.

export async function FrequentlyBought({ productId, variant }) {
const products = await getFbtProducts(productId)
if (products.length === 0) return null
return (
<Impression
event="fbt_module_viewed"
properties={{ product_id: productId, variant, count: products.length }}
>
<section>
<h2>Frequently bought together</h2>
{products.map((p, i) => (
<FbtItem
key={p.id}
product={p}
position={i}
sourceProductId={productId}
variant={variant}
/>
))}
</section>
</Impression>
)
}

<Impression> is a client wrapper that fires its event when the section enters the viewport. The other two events (fbt_item_clicked, fbt_added_to_cart) live in <FbtItem>.

'use client'
async function addFbtToCart(productId: string, sourceProductId: string, variant: string) {
track('fbt_added_to_cart', {
product_id: productId,
source_product_id: sourceProductId,
variant,
})
await addToCart({ merchandiseId: productId, quantity: 1 })
}

The event fires before the network call, in case the user navigates away mid-mutation. The variant rides along on every event so the warehouse can compute the AOV lift without joining back to the assignment table.

That’s the slice. Variant traffic is being assigned, the module renders for half the visitors, every meaningful action is instrumented. Nothing was built for hypothetical future modules. When the next test wants a similar shape, copy this file and change five things - extract the shared parts at the third or fourth instance, not the first.

  • Slicing too thin. A “slice” that doesn’t actually answer a question - no events fire, the flag is fake, the data is mocked - isn’t a slice. It’s a UI prototype with extra steps.
  • Skipping the boring layers. Tracking is the layer most often left for “next sprint”. The variant ships, the data is incomplete, the test is unanalysable. Tracking belongs in the slice, not after it.
  • Refactoring the slice before the second one exists. Pull abstractions when there are two or three examples of the same thing. Not before. The premature RecommendationsModule base class will be wrong for the next test that needs to vary something it didn’t anticipate.
  • Slicing a spike. A spike (a throwaway exploration) doesn’t need a full slice. Don’t ship analytics or flags for code that’s going to be deleted next week.

Experiment velocity is mostly a function of how fast a hypothesis becomes a deployed, tracked variant. Teams that vertical-slice ship five tests in the time a horizontal team takes to ship one. The difference compounds over a year into a different programme entirely.

Most CRO debt - the kind covered in technical debt as a CRO problem - is the residue of horizontal builds. The recommendations service that took six months and was never used. The analytics layer that was perfect for events nobody ever wired up. Vertical slicing isn’t a CRO concept by origin but it’s the working pattern that lets a CRO programme stay productive.