Feature-Flagging a Major Design System Upgrade
Author

Date Published
Share this post
Our native mobile app recently went through a major visual refresh. Alongside new colors and typography, we upgraded the shared design system library that powers most of our UI. Design tokens were changed, component variants were removed, and some components saw major structural and semantic changes.
Adding to the complexity, we needed to run the old and new design systems in parallel to maintain our normal release cadence. Freezing app releases to allow for a "big-bang" cutover would be too disruptive.

The Problem
Three constraints guided our approach:
- The redesign migration would run longer than our release cadence.
The work was slated to span more than a month, but we normally ship native app releases every two weeks. At least two store submissions would go out while the migration was mid-flight. Each release had to still support the old design system while we kept building against the new one. - This was more than a theme tweak.
The refresh included structural changes to UI components and some breaking prop changes tied to semantic changes in our underlying design system tokens. Runtime flags or branching would extend our timeline. - We wanted to ship the new UX as soon as possible, but we also wanted to improve our foundations along the way.
There’s a natural tension between product teams wanting to move fast, and systems teams that want to move slower (to move fast). We wanted to launch the new design quickly for our users, but we also wanted a clean migration to the new design system so that we’d continue building on a solid, consistent foundation.
The Approach
Our team utilizes build flags (frontend compile-time feature flags), so we already had a source of truth to build upon that the team regularly used in development workflows.
We decided we'd have one flag to select which version of the design system library a build would use: the stable release for production, or a pre-release snapshot for migration work (the "incoming" library). Screen-level UI changes were gated separately, allowing the library upgrade and the screen-by-screen migration to be toggled independently (and most importantly, cleaned up separately post-launch).
To make this work, we had to coordinate several moving parts: build flags, bundler import redirects, generated type declarations, thin adapter components, and CI checks. It felt a bit like mad science, and at times we wondered if we were overengineering. In the end it allowed us to merge 230 migration-related changes over 6 weeks across four teams without interrupting our ability to ship parallel improvements to our users.

Build-Time Package Swapping
Using package manager aliasing, we installed both versions of the design system side-by-side:
1{2 // package.json3 "dependencies": {4 // ...5 "design-system-incoming": "npm:design-system@2.0.0",6 "design-system": "1.1.1",7 }8}
Our bundler (configured via those build-time feature flags) redirected standard import { Button } from 'design-system' statements to whichever library version the flag selected.
The API doing the heavy lifting here was Metro’s resolveRequest which we’d conditionally call based on the flag value and the package import it was matching. When the package swap flag was off, we wouldn’t do any redirects to the incoming design system package.
The Adapter Layer
Because the new library package was not a drop-in replacement, token deprecations quickly rippled into component props. Typography variants no longer existed, icon styles were removed, and some layout props were deleted entirely.
Instead of blocking the migration on a repo-wide migration effort that wouldn’t necessarily translate into immediate visual improvements, we built thin adapter components. These adapters accepted the old API, translated the deprecated values to their new equivalents, and rendered the updated component underneath. These adapters were swapped in by the bundler at build time for the incoming/true side of the build flag, allowing us to see the immediate visual impact across the app.
1export const Button = ({ outgoingProp, ...props }: OutgoingButton & IncomingButton) => {2 const incomingProp = mapOldToNew(outgoingProp);3 return <IncomingDesignSystemButton incomingProp={incomingProp} {...props} />;4};5
Feature teams could update their call sites at their own pace, and we didn't have to wait for an incremental migration to finish before we could see the user-facing impact in our app. As a bonus, these adapters served as living documentation for what had changed: every breaking mapping lived in one central place.
Dual-Path Type Safety
Swapping packages at build time was only half the battle; TypeScript also needed to understand which APIs existed during compilation, so we had a sense of safety and still received prop autocompletion in our IDEs.
We used conditional type definitions that swapped based on the active build flag. In practice this meant the types for both sides of the flag were “widened” to support prop uses that work for either system package. We also updated our CI pipeline to run our typechecks against both flag values on every single pull request. If either the outgoing production or the incoming migration paths drifted, the build failed before it could ever reach a release candidate.
To make this happen, we leveraged TypeScript’s compilerOptions.paths to alias our library package types just like we redirected our runtime imports. When the package swap flag was off, this would proxy through to our outgoing library’s existing types. When the swap flag was enabled, the generated types augmented the incoming library’s native types with any adapter types that had been defined.
How It Played Out
During the migration, production builds stayed on the stable library with the legacy theme. Separate internal preview builds used the incoming design system through the adapter layer, allowing teams to review real-time migration progress in parallel. We were able to keep our normal release schedule and avoid disrupting any team’s velocity.
Once the design refresh shipped broadly and the incoming package became the new stable release, our mobile infrastructure team switched to rapidly tearing down the scaffolding. We removed the flags, bundler redirects, and dual package installs. We were able to translate each build time adapter to their call site prop updates without the pressure of shipping to users since the redesign was already live.
Takeaways
- A package swap is a unified runtime and types problem. If what runs and what compiles disagree, you will spend weeks manually verifying screens and fixing issues.
- Adapters buy time for refactoring and provide early visual feedback. They let you assess the impact of a system-level change sooner so you can course-correct early, and allow for the refactor to unblock the release.
- Plan for the teardown on Day 1. The swap layer was always treated as temporary debt. Designing it to be easily deleted prevented us from accumulating long-lived branching & complexity in our codebase. It's also an important part in a smooth refactoring.
Would we do this again? Maybe! The unique mix of constraints we faced led us down this path, but it’s certainly not a one-size-fits-all solution. Ultimately, I wanted to highlight the power of combining build-time tooling with the flexibility of JavaScript bundlers like Metro. It's a strategy you could reach for to overcome the tricky release gymnastics so common in native app development.
Share this post
Related Posts

Teaching Our Merge Requests to Teach Back
How to improve AI code reviews: turn merge requests into learning tools with context, patterns, and engineering decisions.

Team GSD - Year 1 Reflection: Unlocking Operational Velocity
Team GSD scaled AI and automation across real workflows, improving operational velocity across multiple departments. Here’s how they did it.