Writing Emails Using React
-
Jack Guy, Software Engineer
- Jul 20, 2022
As part of our effort to connect users with great local businesses, Yelp sends out tens of millions of emails every month. In order to support the scale of those sends, we rely on third-party Email Service Providers (ESPs) as well as our internal email system, Mercury.
Delivering the emails is just part of the challenge—we also need to give email developers a way to craft sophisticated templates that conform to our Yelp design guidelines. In the past, Yelp web and full stack engineers would rely on our legacy template language, Cheetah, to write emails. However, as the Yelp design language continued to evolve, this approach began to show its age: the code wasn’t maintained and visuals were no longer consistent with those of our apps and website. Additionally, Cheetah is a little-known language that represents an entirely different development workflow from what Yelp engineers are most accustomed to writing in their day-to-day work. Essentially all new web development is done in React.
In 2021, we set out to solve these problems by creating an email development system based on React components. Since its general release, this system has been used to develop more than a dozen new email types to send at scale, with millions of emails sent to date.
In this blog post, we’ll detail how we’ve repurposed elements of our website’s infrastructure to support email development, and how these systems address common problems encountered by email developers.
How It Works
Yelp web developers write React code in our frontend monorepo, where they can create new packages, write components, and deploy pages. By using React for email development as well, developers are able to write emails in familiar ways:
- New emails are scaffolded using Yeoman.
- Each email template is its own React component. The component’s children are made up of shared email components that are imported from elsewhere in the monorepo.
- Our React email code is type checked and linted, following the same rules as the rest of the monorepo.
- Developers can write Storybook examples for emails and see them displayed in the same way as our web components.
- Emails are tightly integrated with our core web infrastructure; building, image imports, CDN asset upload, and i18n are all supported for free.
We’ve also created tooling for email developers that want to send tests to real email clients. yelp-js-email extracts markup from Storybook examples and uses it as a basis for generating a preview email. With a little bit of AST modification, we can generate a test application which renders a preview email that closely conforms to the process used in a production send. The outcome is that developers are able to create and test new emails seamlessly without any backend work or campaign configuration.
When it comes time to send an email, developers release a new version of their package containing the email component. Next, they add the released version to our email-rendering microservice. After making a backend config change describing the new email campaign, it’s ready to be sent. Our backend developers can then submit email payloads to Mercury through Yelp’s data pipeline which triggers a server-side render of the email component. After some post-processing transformation to prepare the rendered HTML for sending, we then pass the email through the rest of our email pipeline on to our ESP. In a matter of seconds, a user receives an email from Yelp that was written with React!
For more details on the implementation of React emails and challenges we’ve encountered, read on.
Setting an Email Client Compatibility Standard
Email clients have a (not undeserved) reputation for being difficult to develop for. Maintaining good compatibility while accounting for the various quirks of dozens of individual clients and platforms is a task that requires expert knowledge, thorough testing, and a lot of patience. Unlike modern evergreen web browsers, which have largely coalesced around a common standard, email clients have gotten away with custom and broken behaviors for years.
The critical takeaway from this insight is that email developers ought to explicitly define email clients that they intend to support. In the same way that developers across the industry have factored declining market share and maintenance costs into their decisions to drop compatibility for Internet Explorer 11, email developers can make choices when it comes to the email clients that they support. Throughout our testing we’ve found that popular email clients have better HTML and CSS compatibility than most developers realize. Outside of a few notable “problem clients” (Desktop Outlook, Windows 10 Mail, etc.), the big players (Gmail, Apple Mail, Yahoo, outlook.com, etc.) largely render spec compliant markup and styles more or less correctly. Common email wisdom, such as the recommendation to never use <div> tags, does not apply to these clients in 2022.
We looked at engagement numbers for our various emails and found that a small percentage of email recipients were opening their mail in legacy email clients. After consulting with our Product team, we determined that we would drop support for Desktop Outlook and related clients. Dropping support means that the text content of the email will still render, but we don’t consider it blocking if the email is otherwise visually broken. By explicitly defining the email clients we intend to support, we:
- Give developers confidence when developing emails that they will display correctly for users within our support standards.
- Get a better sense of the absolute market share of our emails.
- Are able to craft more compelling, visually appealing, and responsive emails for the majority of Yelp users.
Creating Email-Compatible React Components
Providing developers with drop-in email components that have already been audited against our support standards is critical. In the same way that web engineers compose pages on the Yelp website by arranging a set of common, consistently designed components, we want to allow the possibility of building emails with minimal custom code required.
We determined early on that our Design Systems team did not have the resources to build and maintain an entirely separate set of React components exclusively for emails. Our design language is constantly evolving across our three major platforms and requires continual upkeep. Adding email into the mix would incur a significant cost. Our approach was then to reuse as much of the implementations of our existing web React components as we could.
This might seem impossible at first glance, as web React components are built for a browser context that involves lots of functionality not supported in email clients (interactivity, statefulness, and even animation). However, at its most basic, React functions just like a classic templating language. Blocks of template code can be conditionally rendered, composed, and injected with data, all in the context of JSX. When server-side rendered, a React component is transformed into HTML. That HTML is what we can assemble into our email body and send as a static email.
We employ two strategies to ensure that our existing web components are able to render properly in an email context: component wrappers, used to refine the prop APIs of our web components, and CSS in JS transforms, used to make individual style tweaks. Where neither approach is suitable we’ll create custom components built just for emails.
Component Wrappers
Each of our repurposed web React components are wrapped in a corresponding component prefixed with “Email” (e.g., Text
-> EmailText
, Container
-> EmailContainer
, etc.). This wrapper component allows us to modify the available props for the component and provide one-off tweaks and overrides.
For example, our standard Button
component supports an onClick
handler, but we don’t want email developers to use it (no JavaScript means it won’t work!). We simply define a new Props
type (at Yelp we use Flow) with the restricted component API. We also use it as an opportunity to document relevant compatibility notes we’ve encountered in our testing, and to make other tweaks to the template before forwarding along props to the underlying Button
component:
CSS in JS Transforms
We’re big fans of CSS in JS at Yelp, and are in the process of migrating most of our React components from SASS stylesheets to Emotion. One of the often overlooked features of writing CSS in JS is the level of control it provides over styles at runtime. Emotion actually facilitates this through custom Stylis plugins, which allow developers to systematically transform their styles while a component is rendering!
We put this to good use for our React email components, tweaking CSS to maximize email compatibility and minimize cognitive load for developers. Check out this example Stylis plugin that helps address a compatibility issue with usage of “var” in property values:
Without this plugin, we’d be hard put to reuse our React Button component in emails. Since the problematic “var” style is embedded in Button’s render method, we’d likely be forced to add an email-specific prop with some branching logic to conditionally remove the CSS. This adds a maintenance burden and introduces a concern with email rendering that our web components would not otherwise have.
Using this custom CSS in JS plugin, we get to maintain the encapsulation of our web components while still making the tweaks we need to use them effectively in emails. This is particularly important for emails that we send using AMP, which validates styles against a very strict subset of web CSS. Using these plugins, we can modify our styles to ensure they conform to the specification.
One other advantage of using CSS in JS to style our emails is that we know we won’t be wasting bytes with styles that we don’t need. When we’re building, our styles will be tree shaken alongside the rest of our components’ JS.
Custom Email Components
We aren’t always able to repurpose an existing web component using these two approaches; sometimes there are fundamental incompatibilities. In these cases, we’re able to write an Email*
component from scratch, knowing that we aren’t unnecessarily introducing duplication. We probably want to revisit the design of these components in an email context anyway. For example, our RatingSelector
component allows users to start a review on the Yelp website:
In an email context, we still want to allow users to tap a star rating to begin a review, but we’re not able to replicate the highlighting behavior on hover in email clients. There’s no way to address this difference using a wrapping component or CSS in JS plugin, so we created a custom EmailRatingSelector
inspired by the original design but better suited for rendering statically.
As another example, at Yelp we use pre-built React components to standardize most of our layout needs. Most notably we use one called Arrange
. As Arrange
relies on some pretty tricky styles and media queries that don’t play nice with most email clients, we decided to create a custom EmailArrange
component in its place. For EmailArrange
we greatly simplified the available options, opting for a fixed layout <table>
, but maintained a similar props API to the web Arrange
component. Developers consuming EmailArrange
will see it work in much the same way that they’re accustomed to, and the implementation details of the email-specific differences are abstracted away from them.
Server-Side Rendering Emails
At Yelp, we’ve found SSR (Server-Side Rendering) of React pages to be a critical component of our web application, positively impacting SEO, page performance, and user experience. Since we need to turn our React email components into HTML for sending, rendering them server-side (ideally using our existing infrastructure) was a critical piece of the puzzle.
The performance characteristics of rendering emails fundamentally differs from serving requests from web traffic. Web traffic tends to scale gently, following predictable curves as users frequent Yelp more often at certain times of the day. Email rendering happens entirely differently. Except in cases where they’re sent immediately in response to a particular user action, emails are typically sent in massive, scheduled campaigns that consist of thousands or even millions of emails in one batch.
We performed some early tests on our legacy SSR system and found that it was a bottleneck—suddenly queuing thousands of requests to render emails at once quickly overwhelmed it. We were forced to throttle the requests at just tens of emails per second, which was unacceptable for the production campaigns we knew we needed to run.
As outlined in a previous blog post, we’d encountered a myriad of similar challenges scaling Server-Side Rendering in other places, so our awesome web infrastructure team invested in a new system that SSRs pages with far greater performance and reliability. Since the widespread rollout of this modern system, we’ve been able to easily scale to thousands of emails sent per second, such that the rendering step is no longer a bottleneck in our pipeline.
When a backend developer writes a batch for a massive email campaign (typically using Spark), they queue email-send requests to Mercury, our internal notifications system (powered by our data pipeline and Kafka). Those sends contain basic information such as the user to send to and the campaign it belongs to, as well as a payload containing data that’s required to render the template. These send requests are ingested by workers in our email-rendering microservice, which in turn triggers a request to our SSR service shard (the payload gets turned into React props). The shard returns our rendered email in HTML, which is then forwarded along to the rest of the email-sending pipeline.
There’s another benefit to rendering emails using our existing SSR infrastructure: since our React web pages are already configured to support making GraphQL queries during SSR using Apollo, we can make online queries to include data in our email templates using our GraphQL API with no additional work needed.
Post-Processing and Sending
Through the previous steps we’ve outlined, we’ve prepared our server-side rendered HTML to be sent to email clients. Even after the initial render, there’s still a little post-processing work to be done.
First, we make an effort to clean up the SSR HTML—in its raw state it’s tailored to be rendered and hydrated in a web browser. Using pyquery we can clean up extraneous script tags and attributes that we won’t use. Next, we want to establish the metadata for the email (subject line, from address, etc.). Inside each React email component, we use a <title>
tag rendered via react-helmet-async to set our subject line. Data attributes on that tag provide the rest of the metadata we need.
Using this approach lets us keep the source of truth for email metadata alongside the component itself.
Finally, we need to inline our styles. Even modern email clients like Gmail suffer from limitations like <style>
tag byte limits that make it necessary to move <head>
tag CSS into HTML element style=""
attributes. There are a bunch of open source options to accomplish this task (e.g., juice and premailer). In our testing, we found that existing Python implementations were far too slow for our needs, sometimes taking upwards of a second. Instead, we opted to write a custom style inliner built on tinycss2 and selectolax. Even with some AST traversal, the implementation is quite straightforward, and we were able to minimize the time we spent inlining styles down to just a few milliseconds.
After performing all these tasks, we construct a basic email HTML structure and forward it on its way. The email is ready to be sent!
Conclusion
By relying on React components and existing Yelp web infrastructure, we were able to architect an email template system that’s easy to use for developers, has up-to-date designs, lowers maintenance costs, and surpasses our performance requirements. In aligning product and engineering needs and clearly defining our email compatibility standards, we spend less time concerned with outlier email clients and more time creating compelling campaigns for Yelp users.
While the approach to sending emails outlined above is from the frame of reference of Yelp’s infrastructure, the overall system can be replicated using some fundamental building blocks like CSS in JS, a mature SSR platform, and an extensible email-rendering pipeline.
Become an Engineer at Yelp
Want to help us make even better tools for our full stack engineers?
View Job