Accelerating Mobile App development at Zalando with Rendering Engine and React Native

We present how we combined our internal React-based UI composition framework Rendering Engine with React Native in a brownfield integration approach that enables us to gradually modernise our mobile app technology stack while at the same time introducing cross-platform technologies to build shared experiences.

photo of Rene Eichhorn
Rene Eichhorn

Senior Software Engineer

Posted on Oct 03, 2025

Introduction

Recently, Zalando decided to start a large-scale migration of the Zalando mobile app, which is currently built in two different architectures and codebases, one for iOS and one for Android. In September, I had the opportunity to speak at React Universe Conf where I provided a high-level view of our approach. This article provides more context and an in-depth look into our decision-making, integration approach, and outlook on how we think about cross-platform customer experience development.

Before going into the technical details, let’s first clarify how we reached the decision to use React Native in the first place. The core requirements we have can be summarized into the following three pillars:

  1. Build & Ship faster: Major architectural changes are often driven by the goal to increase speed and efficiency long-term. We operate in a fast-paced environment where we want to experiment with new customer experiences for our 52M+ fashion, beauty, and lifestyle customers. Therefore, to enable our teams to continuously iterate, we need to ensure features can be built quickly on all platforms with as little effort as possible.
  2. Progressive adoption: Rebuilding our entire app at once is out of scope due to complexity. Migrating more than 90 screens at once is not an option. Our technology choice needs to be adopted in a safe and iterative way so that we can evaluate it on a subset of screens and traffic, before rolling it out to millions of customers and putting in the effort to migrate all screens.
  3. Including Web: So far, the Zalando website and the Zalando apps were going into almost completely different technical directions and while each platform does indeed have its own challenges, there are also a lot of shared concerns. Many capabilities that have been built over years, such as backend-steered UI, composable and modular components and other capabilities should not be lost during this transition but rather built upon.

Evaluating React Native

Arriving at the decision to use React Native happened in several steps for us. Before integrating React Native into our codebase, we began with a simple but very expressive proof of concept that replicated our current app experience, including all typical needs for an application such as navigation with react-navigation, simple to complex animations with react-native-reanimated, video playback with react-native-video and a custom turbo module to showcase native interoperability. Having access to these strong, community-maintained packages helped a lot in building a prototype with a lot of content. Yet, ultimately proving a new technology to be production-ready comes with additional requirements like observability, analytical tracking events, data fetching and caching, state management, deeplinking, and other capabilities we mentioned earlier.

Building a scalable React Native application typically demands a complete architectural design. However, we found ourselves in a unique position: the Zalando website already possessed a well-established, scalable framework built on top of React. By integrating this internal framework into React Native for our proof of concept, we achieved a production-ready setup with live data access in just a few weeks.

This internal framework is what we call Rendering Engine and its essence is a concept of renderers, which are supercharged React components that add common application requirements by default. Imagine you write a component and it automatically becomes observable with metrics and traces, handles data fetching and caching, state management and provides an easy way to trigger analytical events, and much more, while at the same time enforcing these components to be independent and as context-insensitive as possible. A detailed write-up about how this works can be found in one of our previous posts that goes into detail of Rendering Engine.

import { module } from "@if/rendering-engine/api";
import * as React from "react";
import * as query from "./query.graphql";

export default tile()
  .withQueries(({ entity: { id } }) => ({
    carousel: { query, variables: { id } },
  }))
  .withProcessDependencies(({ data }) => {
    if (data === null) {
      return { action: "error", message: "No collection data found." };
    }
    return {
      action: "render",
      data,
      tiles: { entities: getCollectionEntities(data) },
    };
  })
  .withRender((props) => {
    const {
      data: { collection },
      tiles: { entities },
    } = props;
    return (
        <Carousel
          {...collection}
        >
          {entities}
        </Carousel>
    );
  });

Enabling development & production readiness

While building web applications with Rendering Engine was an established process within Zalando, integrating React Native into an existing large codebase proved to be a new challenge. Having attempted such integration, we ran into several problems:

  • Native dependency conflicts - React Native or community packages using native packages in different versions than we did.
  • No clear separation - We asked ourselves where to put the React Native code and how to embed it properly in our apps’ codebases? Git submodules were one option but come with a lot of other issues and they don't enforce strict separation.
  • Bad developer experience - Building a large native app can be slow, despite build caches. Having to build the entire app to get started with React Native, especially for engineers coming from a web background (and unfamiliar with tools like Android Studio and Xcode), posed a major problem, affecting productivity and causing friction in onboarding web engineers.

Getting around these problems turned out to be more complicated than initially expected, but in the end, we solved the challenges and arrived at what we call the React Native as a package architecture.

React Native as a Library dependency graph

Figure 1: React Native as a Library

The essence of this approach is to build the React Native part of the Zalando app just like any other React Native application, with one little tweak: we put our React Root Component and initialisation logic into an npm package called the “Entry Point”. This entry point is consumed by our standalone Developer App with a standard React Native environment. Hence, in the developer app, we get all the benefits of React Native, as in any greenfield app, with full isolation from the legacy architecture. We’ve added our own developer menu on top of React Native's default developer menu (the one you see when you shake the device), which allows developers to quickly change between JavaScript bundles (released versions, pull request builds, and local) and many other developer experience utilities.

Developer app with custom menu for switching between pages and other utilities

Figure 2: Developer App

The other consumer is the Framework (SDK), which is a native library/package that contains the entire React Native stack hidden behind a simple-to-use interface.

public class ReactNativeViewFactory {
  public func initialize()
  public func loadView(
      _ deepLinkProps: DeepLinkProperties,
        launchOptions: [AnyHashable: Any]? = nil,
  ) -> UIView
}
public interface ReactNativeViewFactory {
    public fun initialize()
    public fun createViewHostedInActivity(
        activity: FragmentActivity,
        screenParameters: ReactNativeScreenParameters,
    ): View
    public fun createViewHostedInFragment(
        fragment: Fragment,
        screenParameters: ReactNativeScreenParameters,
    ): View
}

A lot of this work has been open sourced by our friends at Callstack in a simple package that you can use yourself!

Interoperability with existing native app

As much as we prefer to keep our new architecture isolated from the existing native app, it’s unfortunately not always feasible to do so. Sometimes there is still the need to communicate between these two quite different systems. For example, when you add a product to the wishlist in the Zalando app, there is a little badge that increments and shows the amount of products in your wishlist. If this happens from the React Native side, we need to tell the native app to update the counter accordingly.

To bridge this functionality, we adopted a standard dependency injection flow that allows communication between the systems while still remaining as isolated as possible. For all sorts of communication, we have the following flow:

  • Create a new turbo module and define its interface with Typescript types.
export interface Spec extends TurboModule {
  addProduct(
    sku: string,
    shouldShowNotification?: boolean,
  ): Promise<void>;
  readonly onProductChange: EventEmitter<ProductChangeEvent>;
}
  • Define a compatible interface (or protocol) that will define the API to be injected on the native side, as well as a place to inject it.
@objc
public protocol WishlistProtocol: AnyObject {
    var onProductChange: ((String, String?, Bool) -> Void)? { get set }

    func addProduct(_ sku: String, shouldShowNotification: Bool, completion: @escaping ((Error?) -> Void))
}

@objc public class WishlistConfig: NSObject {
    @objc public static var delegate: TurboWishlistProtocol?
}
  • Lastly, in the native app implement a class that conforms to the interface and injects itself into our Framework (SDK).

This creates an easy way to communicate with each other, but it also creates clear boundaries and contracts. A neat side effect is that now our standalone developer app can implement those same interfaces with a mocked version, allowing us to keep using the developer app even when testing features like the wishlist.

Cross-platform including Web

Relying on the framework initially developed for the Zalando website enables us to share core functionalities and code across both app and web platforms. Furthermore, it unifies our approach to building customer-facing applications at Zalando by standardizing underlying concepts like Renderers. This is great, but we want to explore going even further with cross-platform development. With Rendering Engine, we can share central and foundational logic like data fetching, analytical tracking, caching, etc., but what if we could share UI as well?

With React Native, there are mostly two different ways to write cross-platform UI components, each coming from a different perspective:

  1. Build components in the normal React Native way using built-in components like <View />, <ScrollView />, <Text /> and so on, and then let react-native-web take care of translating these components to the HTML elements for the browser.
  2. Build components with a subset of HTML and map the HTML elements to their respective react-native components with react-strict-dom.

So we either write HTML and map that to React Native or we write React Native and map that to HTML. Although both options were feasible, we ultimately opted for react-strict-dom. Our decision was driven by the desire to select the most future-proof solution, which react-strict-dom appeared to be. Furthermore, we believe that HTML and CSS are incredibly expressive, have evolved over many years, and are likely to remain relevant for years to come. In contrast, any other form of UI representation could potentially become obsolete at any point. React-strict-dom also has no additional runtime cost on the web because a build step removes all unnecessary abstraction layers.

import { css, html } from 'react-strict-dom';

const styles = css.create({
  button: {
    backgroundColor: {
      default: 'white',
      ':hover': 'lightgray'
    },
    padding: 10
  }
});

function MyButton() {
  return (
    <html.button style={styles.button}>
      A cross-platform button
    </html.button>
  );
}

Building a cross-platform component library

With react-strict-dom as our cross-platform UI layer, we built a component library for Zalando’s own design system, which includes components and styling for typography, buttons, cards, dialogs, etc. However, building components cross-platform can sometimes be quite restrictive because, no matter which UI layer you choose, you will be limited to a subset of features that work identically across all platforms, and anything not universally supported is stripped away. For us, this is unacceptable, as we want to benefit from cross-platform code and not limit ourselves. Luckily, react-strict-dom and the Metro bundler possess a few utilities that help in that respect.

  • Platform-specific imports: If you create a Foo.native.ts alongside a Foo.ts file, whenever you import "./Foo" Metro will automatically choose between those two files depending on the target platform; .ios.ts and .android.ts are available to make it even more specific if needed. Especially in a component library, this is great because even if you have completely different implementations for different platforms, as long as the component's props are the same the consumer doesn't really care about the underlying implementation and is fully abstracted away from platform-specific code. We started using a simple pattern where types would live in a separate file so that we can have safe type checking between multiple implementations.

Component Library showing platform specific imports

Figure 3: Component Library
  • React Strict DOM’s compat: While react-strict-dom’s mapping works great, sometimes we want to extend or adjust props passed to the real underlying native component to gain more control over it. React Strict DOM provides a simple-to-use API that allows exactly that.
export component CustomSpan(...props: FooProps) {
  return (
    <compat.native
      {...props}
      aria-label="label"
      as="span"
    >
      {(nativeProps: React.PropsOf<Text>)) => (
        <Text {...nativeProps} />
      )}
    </compat.native>
  )
}

One last missing piece for a cross-platform component library that we haven’t talked about yet is styling. For our library, we enhanced our styling capabilities with StyleX, which works hand in hand with react-strict-dom and we use it to support theming as well as polyfilling a subset of CSS capabilities like pseudo-classes and media queries. This means that we can use styling variables, such as font sizes, colors, borders etc., which we call tokens just like you’d use CSS variables on all platforms. For the web all styling and variables are transformed into a regular CSS file.

import { tokens } from "@zds/tokens/tokens.stylex";

export const DefaultMessage = ({ style, ...props }: MessageProps) => {
  const defaultStyle = [styles.primaryStyle, style];

  return <BaseMessage {...props} style={defaultStyle} />;
};

const styles = css.create({
  primaryStyle: {
    backgroundColor: tokens.colorBackgroundDefault,
    borderWidth: tokens.borderWidthS,
    borderColor: tokens.colorBorderSecondary,
    borderStyle: "solid",
  },
});

Where we are now

For us, the migration is still ongoing but we have successfully migrated a few screens, ranging from major to minor, including Zalando’s new front screen Discovery Feed, which has a strong focus on media, proving that media-heavy content can also be delivered with React Native.

Zalando's front screen - Discovery feed

Figure 4: Discovery Feed

Making mistakes and learning from them is a normal process in software engineering. Along our first releases we made a lot of discoveries along the way. A few highlights include:

  • Launching early turned out to be crucial. The first screen we migrated was a low-traffic and very simple screen; however, even in this simplest scenario, we learned a lot. It provided opportunities not just to test the technology early without breaking a major feature, but also to build proper observability based on real customer experience.
  • Writing cross-platform code is a balancing act between saving development time and limiting yourself to cross-platform constraints. It’s important to accept that having 100% code shared between all platforms or even between iOS and Android, is not the goal, just like writing everything in JavaScript and avoiding native code is not the goal, and that’s totally fine.
  • Earlier, we mentioned our approach to interoperability between React Native and the existing native apps; however, getting there was not an easy step and required a proper process. Especially when combining three environments into one (TypeScript, Swift and Kotlin) it’s crucial to first properly define these API contracts and ensure that all involved environments are compatible with this contract as early as possible. Otherwise, you run into challenges where the API design might not be feasible on all platforms, requiring you to undo work that has already been done.

With our foundation in place, we're now focused on accelerating migration velocity while maintaining the quality bar our customers expect. This is an exciting time for mobile development at Zalando, and we're grateful for the strong internal support and the robust open-source ecosystem that made this possible. We look forward to collaborating with the community and contributing our learnings back to the ecosystem.


We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Software Engineer!



Related posts