<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[Ambrook Engineering | RSS Feed]]></title>
        <description><![CDATA[Notes from the engineers building Ambrook.]]></description>
        <link>https://ambrook-staging.com</link>
        <image>
            <url>https://ambrook-staging.com/img/share/careers.jpeg</url>
            <title>Ambrook Engineering | RSS Feed</title>
            <link>https://ambrook-staging.com</link>
        </image>
        <generator>RSS for Node</generator>
        <lastBuildDate>Sat, 10 Oct 2026 12:59:05 GMT</lastBuildDate>
        <atom:link href="https://ambrook-staging.com/rss-engineering.xml" rel="self" type="application/rss+xml"/>
        <pubDate>Sat, 10 Oct 2026 12:58:56 GMT</pubDate>
        <copyright><![CDATA[All rights reserved 2026, Ambrook]]></copyright>
        <item>
            <title><![CDATA[Migrating Fast with Agent Graphs]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Every large migration eventually forces a choice between two bad options.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The first is the mega-PR. Models like Fable or Astra can now produce tens of thousands of lines of code in under an hour, which makes merging massive projects in one big swing more tempting than ever. But massive PRs are near-impossible to review closely, can be a mess to rollback properly, and are not a good fit for a product like Ambrook that is a critical finance system for thousands of businesses across the country.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The second is the more classical, conservative approach: build new components or classes alongside the old ones and move call sites over gradually. Each PR is safe to review and merge, but the codebase lives in two systems for months at a time. While mid-migration, engineers and agents need to learn both APIs, and new code keeps being written against the old API, leading to delays, confusion, and headache.</span></p><figure class="sc-50098300-1 fWGEDu"><div class="sc-50098300-0 cJgFdk"><table><thead><tr><th></th><th>Short migration time</th><th>Long migration time</th></tr></thead><tbody><tr><td>Big diffs</td><td>The mega-PR: fast, unreviewable, with an all-or-nothing rollback</td><td>The stalled "V2" branch that never merges</td></tr><tr><td>Small diffs</td><td>Our goal</td><td>Incremental migration: safe per PR, slow overall</td></tr></tbody></table></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This summer we migrated Ambrook’s design system from Material UI (MUI) to Base UI. After years of being well served by MUI, we had reached a breaking point, having overridden nearly all of MUI’s style defaults in favor of our own styles. Base UI’s headless, agent-friendly approach gave us the web accessibility best practices both libraries include without the drawbacks.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The migration was massive, spanning 54k line changes across 794 files, in a <a href="https://ambrook.com/engineering/infrastructure/building-a-cross-platform-web-and-mobile-app-from-the-ground-up">cross-platform design system that targets React web and React Native</a>, and we completed it in just over two weeks without any major regressions or customer disruption.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">To get this outcome, we wanted to break the false choice between mega-PR and drawn out migration, in favor a few project goals:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">All changes should be packaged in small or simple diffs a human can actually review</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">No “overlap” period where the codebase has a confusing mix of old and new patterns</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">No user disruption or component regressions</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">No API churn for web or native consumers (the change should remain within the design system wherever possible)</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Leave the system better tested at the end.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Rather than building as a mega-PR or one massive stack, we ran the migration as a <strong>graph</strong>. We combined long-lived planner agents, groups of cloud worker agents, thoughtful testing, human review, and rapid bug bash iteration in a process we coordinated to get the best of both worlds. Here’s how we broke it down:</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Our Graph Design</h2><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1404" height="1938" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/f350bbaaf1075ff98a3687393693085d3a58328b-1404x1938.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Stacking our migration into a hundred small pieces wasn’t tenable because stacks are serial, with each change affecting the next downstream. Instead, we worked to simplify to create isolated, verifiable, parallelizable tasks that allowed us to break down the project into four phases. Each phase fanned out parallel worker agents, then brought their output back through human review, following the same basic steps:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">A long-lived Fable planner agent running in Claude Code held the context for the migration, writing and proposing a plan.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">A human reviews the plan, giving feedback on prototype PRs and the proposed batching to minimize conflicts.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">The planner writes a brief per batch and files it as a Linear ticket assigned to <a href="https://niteshift.dev/">Niteshift</a>, one of our cloud-based coding agents, running either Opus or Sonnet, depending on the ticket complexity. Niteshift allows these tickets to be addressed by an independent cloud agent with its own environment, branch, and context window, not as a sub-agent inside the planner’s session, so one crashed or rate-limited session can’t take the batch down with it.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Each Niteshift agent returns a separately mergeable PR, incorporating agentic code review feedback.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">A human reviews and merges, either independently or as a stack (depending on the phase).</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We routed tasks to the appropriate coding model based on the kind of judgment they needed. Mechanical, well-specified batches (test writing, simple bug-fixes) went to Sonnet. Planning, dispatching, and the core migration, where one agent had to hold the whole system in context, went to Fable. Visual polish and complex component migrations went to Opus with a long context window. For review, we used multiple adversarial code review agents from different providers, so the reviewers didn’t share the authors‘ blind spots.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">1. Planning & Prototyping</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The first phase was prototyping, and most of it was meant to be thrown away. Agents produced exploratory mega-PRs that migrated large parts of the app at once, so we could see how it felt and where it broke. Alongside them, a handful of careful single-component PRs showed what “done right” looked like for one component.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">From those, the plan made a decision for every component:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Base UI</strong>, where it had the right primitive. Tooltips, menus, dialogs, selects, and popovers all moved onto Base UI’s headless components.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Plain JSX and CSS</strong>, where MUI had been doing little. Our <code>AmSpinner</code>, <code>AmSkeleton</code>, and <code>AmLink</code> components were easier to write from scratch than use a library for.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>A dedicated library</strong>, where Base UI had nothing to offer. Calendars and date pickers specifically aren’t provided by Base UI, so we used React Aria, whose timezone-less <code>CalendarDate</code> matches how we store dates.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We also leaned on standard best practices for planning large migrations. In our design system, component props live in a <code>.types.ts</code> file shared with native, and those interfaces stayed frozen (with one planned exception, covered under Codemods), so that we didn’t run into conflicts between PRs while updating consumers. We also worked to avoid bundling any forward fixes that could clutter or complicate reviews.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">2. Tests</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Before we migrated the component implementation, we first filled in the gaps in our component tests. We worked to ensure that both component behavior and appearance was tested, so that our reviewers could more easily spot regressions in our migration PRs.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">First, we expanded our Storybook harness. Prop galleries and auto-docs render each component in isolation, one prop at a time, but the regressions we were worried about would appear in a specific combination of props: a chip whose padding collapses inside a dense ledger row, a destructive menu item that loses focus styling, a form that misbehaves inside a dialog’s focus trap. We added dozens of stories, modeling the combinations of component props for each story based on the real-world use cases across our product, and used sanitized fixture data to ensure the cases were realistic.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Second, we ensured that every component had a suite of component tests for the behavior stories can’t see: keyboard navigation, focus, value parsing, and accessibility roles, all of which were provided by Material UI. A planner enumerated test cases per component and split them into eight batches, each a ticket referencing the shared plan. The resulting nine PRs added about 11,000 lines of tests (roughly 1,270 cases), giving future migration workers a spec to build against.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Because these test PRs were independent of each other, we didn’t need to stack them all up, and could merge them as they were ready, without any coordination overhead.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">3. Codemods</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Most of the migration left component interfaces untouched and required the flexibility of an LLM to decide how to best adapt Material to Base UI. Icons, we realized, were the only exception. <code>AmIcon</code>, the component every icon in the app renders through, wasn’t easily compatible with Base UI without API change, which meant editing the hundreds of call sites across our app. That kind of change is formulaic, so a codemod fit it better than hand-written or agent-written edits, and we ran it before swapping any implementations to Base UI. Getting the one interface change out of the way first lets every later change keep true to our “don’t change the component API” rule.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The change touched about over 600 files, with eight batches of roughly 100 files ran in parallel. Once each PR passed CI, we cherry-picked all eight onto a throwaway branch and tested the combined result for visual regressions. The batches were then reviewed separately, stacked, and merged together as a single squashed commit.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">4. Component Migrations</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">What was left after merging tests and codemods was the actual migration of our design system components from MUI to Base UI. Our goal was to merge a single stack of PRs in one go.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We forked this phase out like the others. A Fable session planned the migration, using example PRs we had carefully reviewed beforehand as a template, fanning out ten Niteshift agents by component family (menus, dialogs, selects, popovers, text fields, and so on), each building its part of the stack with one commit per component.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">With the stack assembled, we deployed it to a staging link and bug bashed the whole app on the new foundation. While bashing and fixing and bashing and fixing could have taken weeks, with <a href="https://supercut.ai">Supercut</a>, we cut this down to just two days end-to-end with a far simpler process.</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Designers, engineers, and other Ambrook team members all recorded themselves bashing the app on real devices, narrating issues they saw along the way. Instead of having to type or capture perfect bug reproductions, Supercut allowed them to just record one hour-long session, vocalizing bugs naturally: “this chip’s padding collapses,” “make this button one size bigger,” etc.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We passed all the Supercut URLs to a single Fable session, which used the MCP to read the transcript, identify issues, merge duplicates identified by multiple people, and filing Linear tickets for each. The Supercut MCP allows agents to get a screengrab at a particular timestamp, which meant that the tickets were rich with context, ready to be picked up.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">After a quick review, we sent all these tickets to their own Niteshift agents in parallel, stacking fixes onto our main migration branch. A quick review on our stack’s staging link confirmed the fixes.</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">In parallel with the visual bashing by humans, we put the stack through its paces through a number of other checks and reviews:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Our adversarial code review agents checked for accessibility, keyboard, focus, contract, styling, effect leaks and worked with Niteshift to automatically fix detected issues.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We sent Fable to smoke test our most critical pages, using browser-use to visually diff production and the staging link.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">The tests introduced earlier (storybook and component tests) ran and flagged issues to Niteshift until they were all green.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Humans reviewed every PR in the stack.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Then, just three days after opening this stack, we merged the rebuilt components into <code>main</code>.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">5. Fast Follows</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Removing MUI was our first milestone, but after merging, we set to work shipping enhancements that had been too awkward or complex to add before we migrated to Base UI. In the weeks since launch, we’ve shipped dozens of improvements to our design system:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Accessibility.</strong> We enhanced the accessibility of dozens of components, from accordion triggers and tabs to selects and autocompletes. Nested menus now work entirely from the keyboard, and every control shares one consistent set of visible focus ring styles.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Motion.</strong> A motion token system replaced MUI’s JavaScript transitions, with popups scaling and fading from their origin, optimized drawers’ sliding animations, and reduced-motion preferences respected everywhere.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>More powerful date pickers.</strong> We shipped a brand new set of calendar and date picker components built on React Aria, enabling us to understand dates entered in shorthand formats.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Design token coverage.</strong> We moved all hardcoded colors to tokens, menus, selects, and autocompletes share one compact treatment, and popups no longer open behind dialogs.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Performance.</strong> We reduced our bundle size by removing MUI and Emotion, moved transitions to run on compositor-friendly properties, and removed unnecessary re-renders.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Component API Changes. </strong>We intentionally avoided changing our component APIs to move towards Base UI’s preference for composition over configuration (the opposite of Material UI’s approach) during the migration itself. We are now decomposing large components like <code>AmMenu</code> into components like <code>AmMenu.Root</code>, <code>AmMenu.Trigger</code>, <code>AmMenu.Item</code>, and so on.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The cleanup mattered just as much for engineers. We removed style overrides written against <code>.Mui*</code> class names, aligned on a consistent <code>dataTestId</code> contract on web and native, deleted unused components, and added lint rules to prevent backsliding.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Silence is Golden</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Two weeks after the first commit, our new design system launched into production to very little external fanfare. Across the project, we merged 60 PRs across over 120 agent sessions, modified 794 files and changed 54,000 lines of code, a total of 4.5B total tokens. When it comes to migrations like these, no news is good news – we had replaced MUI without any major regressions.</span></p><figure class="sc-50098300-1 fWGEDu"><div class="sc-50098300-0 cJgFdk"><table><thead><tr><th>Phase</th><th>Sessions</th><th>Input</th><th>Output</th><th>Cache writes</th><th>Cache reads</th><th>Total Tokens</th></tr></thead><tbody><tr><td>Planning</td><td>15</td><td>0.6M</td><td>2.1M</td><td>21.0M</td><td>370.6M</td><td>394.3M</td></tr><tr><td>Tests</td><td>21</td><td>1.9M</td><td>3.1M</td><td>29.2M</td><td>575.3M</td><td>609.5M</td></tr><tr><td>Codemods</td><td>13</td><td>0.8M</td><td>1.8M</td><td>28.8M</td><td>672.5M</td><td>703.9M</td></tr><tr><td>Component Migrations</td><td>48</td><td>6.8M</td><td>8.3M</td><td>81.1M</td><td>2,040.0M</td><td>2,136.2M</td></tr><tr><td>Fast Follows</td><td>24</td><td>2.0M</td><td>2.3M</td><td>28.4M</td><td>661.0M</td><td>693.7M</td></tr><tr><td>Total</td><td>121</td><td>12.1M</td><td>17.7M</td><td>188.5M</td><td>4,319.4M</td><td>4,537.6M</td></tr></tbody></table></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We’re using agent graphs to ship large features, tech debt cleanup, and other larger migrations across Ambrook; we no longer have to choose between a mega-PR and a slow, prolonged migration. Using graphs and parallel PRs that factor out dependencies, we can merge code faster and more confidently.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Human review is a critical part of moving quickly and confidently, especially when building accounting and payments software. By designing our graphs to optimize for reviewability, we built the confidence we needed to make sweeping, ambitious changes.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"></span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/ai/migrating-fast-with-agent-graphs-design-system-material-ui-base-ui</link>
            <guid isPermaLink="false">71213acb-144f-4b2a-9f65-dad3282df8a8</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Dan Schlosser]]></dc:creator>
            <pubDate>Fri, 07 Aug 2026 01:44:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Optimized Real-time Firestore Changelogs with BigQuery]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Ambrook models complex financial worlds. For books to be accurate, there must be a database entry that represents each financial record that makes up a business’s finances. These records are interconnected in a complex web that also changes over time, which means that having an auditable changelog is essential. The paper trail enables us to confidently make changes and have the flexibility to reverse engineer how data was changed whenever we need.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Earlier this year, our changelog system hit a breaking point. We’d stored changelog records in Firestore for years, but coming off a massive period of growth (a 40x increase in our customer base and our changelogs collections growing to be 100x the size of the original set of records), we knew a change was needed.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This is how we migrated our changelogs to BigQuery with no downtime and saved over $100K in projected annual infrastructure costs.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">The changelog stack</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Changelogs tell a story about a resource. They’re useful for analytical insight and debugging, storing a before and after carbon copy of a resource along with some metadata.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#698996">type</span><span> </span><span class="token maybe-class-name" style="color:#698996">ChangelogRecord</span><span class="token" style="color:#60564C"><</span><span class="token" style="color:#BE5557">T</span><span class="token" style="color:#60564C">></span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  id</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#536651">string</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  before</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">T</span><span> </span><span class="token" style="color:#60564C">|</span><span> </span><span class="token null nil" style="color:#698996">null</span><span class="token" style="color:#5F6364">;</span><span> </span><span class="token" style="color:#60564C">// null on a create</span><span>
</span><span>  after</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">T</span><span> </span><span class="token" style="color:#60564C">|</span><span> </span><span class="token null nil" style="color:#698996">null</span><span class="token" style="color:#5F6364">;</span><span> </span><span class="token" style="color:#60564C">// null on a delete</span><span>
</span><span>  metadata</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    version</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#536651">number</span><span class="token" style="color:#5F6364">;</span><span> </span><span class="token" style="color:#60564C">// monotonically increasing, derived from the change time</span><span>
</span><span>    changeTime</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">Milliseconds</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    createTime</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">Milliseconds</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Each tracked collection stores its changelogs in a subcollection nested under the document they track. A transaction at <code>ocean/{oceanId}/fish/{fishId}</code> keeps its history at <code>ocean/{oceanId}/fish/{fishId}/fishChangelogs/{changelogId}</code>, where the changelog ID is derived from the nanosecond timestamp of the change.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Changelog generation happens asynchronously. Every tracked collection has a deployed Firestore on-write listener. When a document is created or modified, the listener enqueues a task on a dedicated changelog queue with the before and after snapshots, and the task handler builds and writes the record. Nothing on the request path ever reads or writes a changelog.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We’ve also built read-path tooling on top of changelogs for our two primary use cases:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Restores.</strong> Given a document and a point in time, we can restore it to any prior snapshot. This gives us a way of salvaging data corrupted by a bug or a rogue internal script that wasn’t reversible in our app.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Diff inspection.</strong> Internal tools walk a document’s changelog history to answer questions like “when did this field change to its current value”, ”what did this look like before it was deleted“, or “what was the exact relationship between these twenty entities at 7:33PM thirty days ago”. While the need for asking such specific questions is fortunately rare, having the ability to ask these questions has been critical for incident response or asserting the correctness of our accounting system.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">While this previous write path proved to be reliable, it was expensive due to the large volume of documents being stored in a database not designed for high-volume use cases.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">To reduce cost, we’d need a different data store, and a replacement for Firestore’s built-in hooks.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Moving to BigQuery</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">BigQuery was the natural choice for a cheap, queryable interface for changelogs. It is inexpensive to store, ingest, and index at multi-terabyte scale. With the right partitioning and clustering strategy, it supports both console and programmatic querying, supporting our changelogs use cases at a fraction of the price.</span></p><figure class="sc-50098300-1 fWGEDu"><div class="sc-50098300-0 cJgFdk"><table><thead><tr><th>Cost</th><th>Firestore</th><th>BigQuery</th><th>Savings</th></tr></thead><tbody><tr><td>Storage (excluding PITR) </td><td>$116/day</td><td>$1.11/day</td><td>$3,450/mo</td></tr><tr><td>Firestore PITR Doubling</td><td>$116/day</td><td>- </td><td>$3,480/mo</td></tr><tr><td>Firestore Read for changelog exports </td><td>$79.73/day</td><td>- </td><td>$2,392/mo</td></tr><tr><td>Pub/Sub publishing </td><td>- </td><td>$50/TB/mo</td><td>(~$2.55/mo)</td></tr><tr><td>BigQuery Subscription</td><td>- </td><td>$40/TB/mo</td><td>(~$3.18/mo)</td></tr><tr><td>Total</td><td></td><td></td><td>$9,316/mo</td></tr></tbody></table></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Moving changelogs to BigQuery removed approximately 35 TB of billable Firestore storage, including document data, indexes, and metadata. That reduced storage charges by roughly $116/day, with another $116/day saved on point-in-time recovery (PITR). Removing changelogs from the nightly full-database export also reduced billable Firestore reads, saving an estimated $98/day, or approximately $2,950 per 30-day month. Write and delete operation costs were comparatively small.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Firestore’s default indexing amplified the cost of retaining this history. It creates ascending and descending indexes for non-array, non-map fields, including nested fields. Each index entry adds storage overhead. For large, deeply nested changelog records used primarily for debugging and investigations, maintaining those indexes was expensive.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The changelog archive now contains approximately 315 million records and 1.79 TB of logical data in BigQuery, costing about $33/month in storage. BigQuery’s US logical storage rate is $0.02/GiB-month, compared with our Firestore region’s $0.108/GiB-month. The savings come from both the lower storage rate and eliminating Firestore’s indexing overhead. BigQuery also includes time-travel storage under logical billing.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Pub/Sub handles approximately 331,000 changelog messages and 2.33 GB of payload per day. Publishing costs $40/TiB, and delivery into BigQuery costs $50/TiB, including ingestion. Together, those charges total approximately $0.19/day, or $5.72/month. Even at 25 times the current ingestion volume, streaming would cost only about $143/month, with storage increasing separately as the archive grows.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Together, storage, PITR, and estimated Firestore read reductions represent approximately $330/day in savings, or $9,900 per 30-day month, after accounting for the main BigQuery archive’s storage and current streaming costs. Current streaming adds less than $6/month. These estimates exclude query costs, network charges, and storage for the retained backfill table.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"></span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Designing the migration</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">For a migration of this scale where data loss is completely unacceptable, we wanted to have a zero-trust approach, assuming anything can break. The design had three main requirements:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Operate atomically;</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Check redundantly; and</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Fail loudly.</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The migration steps overlap by design, and all steps are independent, and can be paused to confirm stability before moving on. Here’s the approach we landed on:</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We started with a core, unopinionated Pub/Sub publisher class that is deliberately schema-agnostic and extensible to changelogs and beyond. This layer handles all the inner-working of the intermediate Pub/Sub layer. Early on, we started dual-writing to Firestore and BigQuery in order to monitor Pub/Sub health, primarily watching for backpressure and any dropped messages. We left the system dual-writing for a period of time so we can build up enough data to confidently compare the diffs.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">For backfilling and parity-checks, a one-off script across 26TiB of data would have taken days to run. In addition to being slow, it would risk hotspotting Firestore read throughput and affecting production latency, overwhelming our disk space with intermediate local files, and potentially causing machines to fail unrecoverably.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We chose instead to launch an Apache Beam pipeline and run it on <a href="https://cloud.google.com/products/dataflow#how-it-works">GCP Dataflow</a>. For each collection it issues a Firestore collection-group query to create several hundred partitions, which workers then read in parallel. From there, the pipeline is a chain of ParDo transforms, each doing one unit of work per changelog: resolve the Ambrook organization ID, serialize the row, write it to BigQuery. Each transform is atomic, so a failed worker can retry without a partial write.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The real run copied 295M changelog documents in four hours. 3,000 Firestore partitions fanned across a worker pool that peaked at 500 workers and 388 vCPU-hours. Dataflow automatically scaled to the appropriate amount of workers, and our total migration cost was less than $100 for both backfilling and parity check.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1605" height="1942" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/8a1a2e94b51c16c56a023534d1f9ebc9bc76b2aa-1605x1942.webp?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As a precautionary measure, all of the rows written to BigQuery were written initially to a temporary table that was eventually merged into the finalized production table, to prevent pipeline defects affecting production tables. Rows could be validated against Firestore before the merge, and any bad batch could be discarded by dropping the temporary table.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Finally, came deleting the Firestore changelogs. To build confidence that we could take this final step, we verified signals below that made us confident that the migration had completed without failures and that we were ready to cut over:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Our dual-writing to BigQuery had run for several weeks without producing any unexpected diffs or monitoring alerts.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We had backfilled and verified parity in isolated and separate Dataflow jobs.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We had refactored all in-app changelog reads to read from BigQuery, monitoring read traffic to confirm that the Firestore collections were unused.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We ran the deletion on a test collection in our staging environment before running it in production.</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">While Dataflow worked well for backfill, it’s not a viable option for deletion. Firestore recommends ramping writes at 500 operations per second, increasing 50% every 5 minutes. Dataflow at 500 workers would exceed that immediately and hotspot the collection. Instead, we built a simple throttled read-then-delete process built on Firestore’s BulkWriter, tuned to what Firestore could actually sustain. Within several hours of writing, the Firestore collections were deleted.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Scale ready</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This migration removed over $100K of annual Firestore costs while preserving full changelog fidelity, and the pattern we established generalizes to any large scale data migration where the old and new systems need to run in parallel before cutover.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Our new BigQuery-based changelog system holds more than 300M records at a compressed storage size of 46 GB, a 38x compression factor from 2TB. Pub/Sub processes around 330,000 messages per day and only operates at just $50 in daily storage cost.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/infrastructure/optimized-real-time-firestore-changelogs-with-bigquery</link>
            <guid isPermaLink="false">eea72508-2b4c-4326-95a4-a0c117ab2bbe</guid>
            <category><![CDATA[Infrastructure]]></category>
            <dc:creator><![CDATA[Adnan Khayyat]]></dc:creator>
            <pubDate>Sat, 01 Aug 2026 19:14:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Building Ambrook’s Software Factory]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/23535c45e65246f349b25be9408de6601f361036-1200x630.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Every engineering team feels the pull between two kinds of work. One is the steady stream of fixes and quality improvements that keep a product polished and resilient. The other is the large, ambitious features that open up new business opportunities.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">With this tension in mind, Ambrook’s Product Pod set out to automate as much of the former as possible so we can focus more engineering bandwidth on the latter. Our goal was simple: increase the throughput of small and medium sized improvements without spending more time on them. We started this project during <a href="https://ambrook.com/blog/inside-ambrook/momentum-month">Momentum Month</a>, where the entire company spent six weeks building agents for every function in the company. In the end, we built a “software factory” that allows operators (i.e., not engineers) to go from product problem definition to a merged PR in less than an hour, and yielded a 5x faster time-to-fix for bugs.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"></span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">The Automation Pipeline</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Our automation pipeline consists of four major components:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://linear.app">Linear</a>: the state</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://zapier.com/">Zapier</a>: the plumbing</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Amos</strong>: our homegrown general purpose background agent</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://niteshift.dev/">Niteshift</a>: the coding agent</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Linear was the natural choice for our state, because it already holds the context of the feature requests and bugs that the agents will act on. We built a simple set of labels to identify each ticket’s state, and used label-driven webhook events to trigger agents via Zapier[0] which update labels in Linear after completing their work.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="2718" height="3372" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/c1113356c83fe1a2a0d93ef052072a8ff8aace9a-2718x3372.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">[0]: There are other ways of reacting to webhooks events. We introduced Zapier as an operator-friendly if-X-then-Y interface but will soon move these production loops to something more easily configured via code.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">Triaging Tickets</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The state machine begins when a ticket receives an <code>Agent Triage</code> label. This will trigger a Linear webhook that proxies through our application to Zapier with the ticket information as well as an idempotency key to deduplicate requests. The Zap will then split paths, separating tickets with a “Bug” label from feature request tickets which do not have that label.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Each path sends a request to our homegrown cloud agent, Amos. Anyone at Ambrook can interact with Amos in Slack or use it programmatically for automation. Amos has a shared set of skills that help it triage our tickets, root causing them, sizing the fix, flagging scope creep, and suggesting priority.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Amos will investigate the ticket, post a Linear comment with its findings, and label the ticket with its classification and reasoning.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Tickets are then sized to qualify or disqualify them for agentic implementation. Features are given a t-shirt size (small, medium, and large), with large features being set aside for an engineer to own.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Bugs are designated into three categories:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><code>Agent Fixable</code> when the fix is well-defined and an agent wouldn’t reasonably find any alternative. Examples might include UI issues like a button not opening the correct dialog, a cache not busting and leading to UI inaccuracies, or an off-by-one invoicing date issue caused by timezones.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><code>Needs Engineer</code> when the fix is trickier, often requiring in-depth knowledge of our accounting or payments systems. They could also be just not easily traceable by the triaging agent, or with a fix complex enough that we don’t have confidence the agent will get it right on its own. This is a dynamic set as the set of things models can do on their own is always increasing.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><code>Not a Bug</code> when the behavior was intended, or the request is really a new feature.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Tickets labeled <code>Not a Bug</code>, labeled <code>Needs Engineer</code>, or sized as a large feature request exit the loop. Humans take over to either re-triage, rewrite the ticket, or close it.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">Implementation</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Any <code>Agent Fixable</code> tickets will trigger <a href="https://niteshift.dev">Niteshift</a>, our model-independent cloud coding agent. Once a PR is up, Niteshift will iterate with one of several automated review agents to get the PR into a healthy shape. When this process is complete, Amos leaves a comment on the original ticket letting the ticket owner know a PR is ready and requests them to preview the fix on a staging deployment.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">If required, Amos can also request input from the ticket assignee or creator and will include instructions on how to send the ticket back to the triage phase with updated context.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">Review</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We have a team-wide policy that the creator of a ticket shepherds it through automation. An operator who files a bug is responsible for answering Amos when it comes back with “Needs Info,” and for testing the fix on the preview link once a PR is up. Niteshift includes a short manual-testing path in every PR description to make that quick. If the fix is wrong, the operator iterates with Niteshift or pulls in an engineer.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Wherever possible we lean on automated code review to shorten review times and raise our confidence in a fix. We use <a href="https://www.greptile.com/">Greptile</a>, Claude code review, and run a home-grown agentic risk assessment that flags large diffs, changes to sensitive systems like accounting or authentication, and anything with undefined behavior. Today, though, nothing ships through the loop without a human looking at it.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Which human a PR goes to depends on how the ticket was classified:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><code>Agent Fixable</code> bug PRs go to an engineering review rotation shared across the whole team.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><code>Needs Engineer</code> bug tickets go to a separate, dedicated set of engineers.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Feature requests go first to a member of our operations team trained to do a first pass on the complexity of the code, acting as a second set of eyes on the automated review. They pull in an engineer when the code seems more involved.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The goal of this split is to give engineers confidence that we are shipping high-quality code without cluttering the standard review request lane with auto-generated PRs.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">Iterating on the loop</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We use two sources of data to improve the loop over time: OTEL sessions from the individual agent runs and GitHub PRs. OTEL session data gives us details on where agents are inefficient: excessive tool calls, errors, duration. GitHub PRs (in particular, the reviews left by both automations and humans) give us insight into what the agent actually produced. Three times a week we run another automation that reviews OTEL session data and reviews to suggest automated improvements to agent instructions, tools, and so forth.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We also maintain several views of the loop in Hex and Linear in order to identify places where the tickets are getting stuck. We surface metrics like time-to-fix, time-to-merge, merges per week, human touches in the pipeline, and how long tickets are remaining stuck when humans are intended to intervene.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1999" height="1365" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/3edcf68b4816f004a47702679aa4d0a8de10db08-1999x1365.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">Faster, safer shipping across our team</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Today, if a customer success operator is on a customer call and notices that some timeline events are off by a day (somewhere in the app, there’s a timezone bug), they can file a ticket, add the agent triage label, and move on to their next call. Meanwhile Amos triages the ticket, Niteshift opens a PR, and automated systems review that PR. The rep tests the fix on the preview link, an engineer merges, and the rep emails the customer back the same afternoon.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">That speed of turnaround back to the customer is paramount. We’ve seen the median time to completion for bugs drop 80% since the beginning of the year, with a time to close under 24 hours and 1 in 4 bugs being resolved in under three hours.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We’ve also seen our teammates in sales, CX, and marketing make <a href="https://ambrook.com/blog/inside-ambrook/from-inbox-to-pull-request">tons of small improvements</a> to the app, often shipping improvements that could have otherwise been easily lost in the backlog. Just as importantly, the Ambrook’s engineering team is not underwater trying to fight our backlog. Instead, we’ve carefully engineered a system that ships the backlog.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">In the end, we didn’t build this to ship more code. We built it so the small fixes and improvements don’t need to compete with our more ambitious work, because both are important in building the best product possible for our customers.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"></span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/ai/building-ambrooks-software-factory</link>
            <guid isPermaLink="false">4fd42b61-3b8b-4c31-a1fa-d2100df85022</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Ayushi Mittal]]></dc:creator>
            <pubDate>Sun, 05 Jul 2026 15:41:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Coding to Cut Carbon]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/d4372eba480e795bec9bd6bda195e37c7cc19486-2000x1050.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">I’ve always been more interested in the <em>application</em> of software than software itself. I feel real excitement when I can map my code to a meaningful real-world contribution, whether that’s responding to a small piece of user feedback or tackling a larger-than-yourself mission like the climate crisis.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As someone with no prior experience in agriculture, Ambrook’s specific <a href="https://www.notboring.co/p/the-founders-letter-mackenzie-burnett?open=false#%C2%A7the-founders-letter-mackenzie-burnett">approach to climate</a> caught my eye because it was bold; it states there <strong>can </strong>be positive-sum scenarios, where profitability on farms is linked to resilient and nutrient-rich land. Determined to contribute, I joined and have since been able to work on projects directly related to this mission.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy"><strong>Listening and Learning</strong></h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Last year, I traveled to North Carolina with a small group to visit the <a href="https://www.sustainableagsummit.org/">Sustainable Ag Summit</a>, where producers expressed a genuine<strong> </strong>desire to build resilient food systems — but still need the math to pencil out. I also visited and learned from our customer <a href="https://breadandbutterfarm.com/">Bread & Butter Farm</a> in Vermont, as they expressed a need for managerial accounting in order to secure an agroforestry grant. And most recently, I traveled to Kansas with my team to meet with another customer, <a href="https://locusag.com/grower-spotlight/allen-schrag-silver-creek-farms/">Silver Creek Farm</a>; we dove into their involvement in the CarbonNow program, where they help producers <a href="https://locusag.com/carbonnow/#when-can-farmers-enroll-">receive funding</a> for implementing practices that sequester carbon.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">From our farm visits and the many talks at the Sustainable Ag Summit, we heard one thing over and over again: <strong>Profitability is the core driver in any business decision, including regenerative and other conservation practices.</strong></span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Something that has really struck me in listening to our customers is how much farmers, ranchers, and other agribusiness owners <em>want</em> to steward their land well. The main hurdle is that they need to trust that these decisions are financially sound — changing practices is a risk that many can’t literally bet the farm on — and there is a gap in the industry for reliable, easy-to-use tools that connect conservation practices to a farmer’s bottom line.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Bread & Butter needed assurance they could maintain profitability while adopting agroforestry practices that reduce greenhouse gases and soil erosion. Silver Creek Farm saw the <a href="https://locusag.com/news-releases/carbonnow-carbon-farming-payments/">CarbonNow</a> program as an opportunity to expand their operation and increase profitability by promoting biological agents which sequester carbon.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">I was really proud to work on two products that exemplify Ambrook’s commitment to bridging this gap — <strong>smart tags</strong> and <strong>receipts.</strong></span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy"><strong>Smart Tags and Metrics</strong></h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Profitability insights have always been offered at Ambrook in the form of multidimensional tagging: Tag your transactions once across different tag types (<em>e.g., the Schedule F “Supplies” Category and the “Apples” enterprise</em>) and receive a profit-loss statement that can be used to file taxes or view a dashboard that describes how your enterprise is performing.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">From our visits with both farms, we learned where tags came up short:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">There were missing tag types: There was no way to model a location or a conservation project.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Tags did not offer a solution to simple managerial accounting.<ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Conservation grant income is distributed to Brandon Bless at Bread & Butter <strong>per acre </strong>and if he cannot track costs by this measurement he can’t know if a practice is profitable.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Allen & Paul Schrag at Silver Creek farm need <em>profitability by field</em> in order to make land purchase decisions and know which combinations of seed, inputs, and practices are most profitable.</p></li></ol></p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">But after a couple months of user experience research, product requirement drafting, and engineering, we were able to ship <a href="https://support.ambrook.com/en/collections/10966681-smart-tags">smart tags</a>, introducing metrics and more tag types!</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="680" height="611" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/adcbc12dd09098733e28377d5b8e417264ca97a8-680x611.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe"><p variant="small" class="sc-8f343995-0 fVqRqW"><em>Get the data in: Defining metrics and splitting transactions by location tags.</em></p></span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Now, the Schrags can tag by <strong>location</strong> in addition to enterprises, projects, and funding programs. On their location tags, they can record <a href="https://support.ambrook.com/en/articles/10161690-metrics-unit-economics">metrics</a> to assist with splitting field input costs <strong>by</strong> <strong>acre</strong> while they tag. For example, a $1000 fertilizer input cost used across 3 fields (50, 100, and 150 acres) would be automatically split (16.7%, 33.33%, 40%).</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1440" height="2837" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/053e38d4593ce8f4b46e808bdede03c96fadb2a0-1440x2837.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe"><p variant="small" class="sc-8f343995-0 fVqRqW"><em>Get the data out: View field profitability by acre.</em></p></span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">These splits roll up to our dashboards and reporting features so the Schrags can view <strong>field-level profitability, as a whole, or per-acre</strong>, at the click of a button, rather than spending hours wrangling data in spreadsheets to try to get those numbers.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Both Silver Creek Farm and Bread & Butter can perform similar analyses across many other tag types and generate <strong>per location</strong> or <strong>per enterprise </strong>profit-loss statements!</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy"><strong>Receipt Management</strong></h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"><a href="https://ambrook.com/education/funding-education/how-to-make-your-farm-grant-application-processes-efficient">Managing conservation grants</a> <em>(compiling paperwork, receipts, and financial data)</em> tends to be a time-consuming activity which limits practice adoption … but not with Ambrook!</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1440" height="970" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/7331500f1be4461d1f83da91fd60e22ec581b88d-1440x970.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe"><p variant="small" class="sc-8f343995-0 fVqRqW"><em>Get the data in: Upload receipts as a net new transaction or match.</em></p></span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">With our receipts product, users can bulk upload receipts and our system will parse receipt fields with AI, either creating a new transaction or matching the receipt to an existing one. When the time comes for a customer like Brandon to fulfill grant obligations and provide evidence of practice adoption, our filtered attachments export can instantly generate a set of attachments for transactions tagged to a certain practice.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="800" height="745" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/82fe2e32337867259b583bae8ec462211dc41296-800x745.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe"><p variant="small" class="sc-8f343995-0 fVqRqW"><em>Get the data out: Export all attachments based on transaction tag filters.</em></p></span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">By offering real-time profitability insights and improving grant management workflows, these features have seen lots of traction. As of this month, our platform has 190 metrics and over 400 metric quantities across enterprise, funding, project, and location tags. We also have roughly 150,000 attachments — our most dedicated receipt user has uploaded around 10,000!</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">From my various farm visits and coding sprints, my big takeaway is you can’t improve what you don’t measure and, as Ambrook’s platform continues to enrich the profitability insights we offer to farms and agribusinesses, we will continue to make meaningful progress toward our climate mission.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/product-engineering/coding-to-cut-carbon</link>
            <guid isPermaLink="false">c61a32b2-4916-437f-96c3-31c5428c5e75</guid>
            <category><![CDATA[Product]]></category>
            <dc:creator><![CDATA[Dylan Hoang]]></dc:creator>
            <pubDate>Mon, 30 Jun 2025 16:18:54 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Bug Day]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/f2263226daf80df332c6c96285935a351f24161d-2000x1050.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">In Ambrook’s early days, when there were only a handful of farmers and ranchers using the platform, we made personalized customer service our top priority. Now, serving more than 2,000 businesses, this commitment hasn’t wavered — even when contending with the challenges of scaling up.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Our Customer Success (CX) team has always played a pivotal role in product development, ensuring that outside feedback directly influenced what we built. We know that quick iteration with our customers — including responding to bugs they surface — helps us build a better product. A side effect is that our customers build a deep sense of trust in our team that gives us the time to continue building new features that turn them into loyal customers.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK"><strong>Balancing Growth and Stability</strong></h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As we scaled, we faced a classic challenge: balancing the need to fix existing issues with the demand to develop new features. In fall of 2024, we introduced several key features, including solving the <a href="https://ambrook.com/blog/engineering/why-generalists-win-ai">messy inbox problem</a>, <a href="https://support.ambrook.com/en/articles/10161693-location-tags">location</a> and project tracking, and the ability to slice a user’s data by operational units — all while seeing a huge growth in our customer base. Given the complexity of the businesses we support, this expansion inevitably surfaced previously unidentified bugs.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">While major issues were always resolved quickly, smaller usability “papercuts” — like needing to refresh a page to load content or inconsistencies with newly launched tags — were often overlooked. With a small team focused on larger feature sets, it became hard to show steady progress to customers. We needed a way to build trust with our users by addressing the pain points that affected their daily bookkeeping, without slowing down new feature development.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK"><strong>Introducing “Bug Day”</strong></h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This past January, after 40xing our customer base in the previous year, we restructured how we tackled bugs. Instead of assigning tickets to engineers at the start of the week and letting them slot fixes into their individual workflows, we dedicate one full day each week for the entire engineering team to focus on resolving customer pain points identified by the CX team. We run the day like a high-energy hackathon — regularly checking in on how the team is doing and aiming to burn down the list of issues as much as possible. At the end of the day, the engineers demo these changes to the CX team so that they can communicate changes back to customers — such as performance increases on page loads, or cutting down bookkeeping time by retaining tags on copied entries.</span></p><blockquote class="sc-8f343995-0 dMJNaA sc-c970a3fe-0 dTHwAY" variant="title" component="blockquote" weight="regular">Over the last five months, we’ve reduced the number of bugs reported per thousand users by 56%.</blockquote><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">These demo sessions have quickly become the CX team’s favorite weekly meeting — filled with numerous 🎉, 🙌, and ♥️s — to show our customers’ (and our) appreciation for these fixes. For the engineering team, it’s turned bug fixing into a shared, high-energy effort, with a tight feedback loop and real momentum. It’s also created a space for our distributed engineering team to come together and rally around a single objective for the day.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK"><strong>The Impact of a Tighter Feedback Loop</strong></h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The results of this shift have been profound:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Greater empathy and alignment</strong>: By having dedicated time and a clear process for working through outstanding bugs, engineering and CX are more in sync with one another. The CX team better understands the tradeoffs that the team needs to make between developing new features or fixing existing problems, and engineers have greater empathy for how solving seemingly small problems can have an outsized impact on customers.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Balancing individual customers with collective impact: </strong>As we’ve grown, we’ve shifted our approach from addressing individual requests to tackling common customer pain points. By involving the CX team in solving these issues, we ensure that our solutions not only address widespread concerns while feeling connected to the impact on individual customers.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>More effective bug fixes</strong>: We’re spending the same amount of time on bugs, but resolving issues in a way that connects engineering and CX more closely. The greater customer understanding and empathy among the engineering team has led to improved testing coverage for new features and a reduction in newly created bugs: Over the last five months, we’ve reduced the number of bugs reported per thousand users by 56%.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">By strengthening team collaboration and instilling a customer-first mindset, we’ve kept the customer experience as the foundation for what we build daily. This approach has helped us maintain trust and responsiveness with our customers, even as our user base expands.</span></p><h3 variant="large" weight="bold" component="h3" class="sc-8f343995-0 efSbhK">What’s Next?</h3><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We grew exponentially in 2024, and we’re just beginning. This year, we’re planning more big feature launches — like inventory and more AI-enabled features — all of which are centered around what our existing and prospective customers need to be successful. As we build these features and think towards another massive year of growth, we also need to ensure we’re building the <em>right</em> things at the right time to maintain the growth we saw last year and draw in a new set of customers.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We’re working on more ways for us to stay close to what a user experiences in our product every day:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>More time hearing from customers during All Hands</strong>: We’ve always shared clips from the CX and sales teams conversations with our customers during our Friday All Hands meeting. We’re going to build on this by hosting a conversation with a customer during this meeting each month.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Focusing on improvements as well as bugs</strong>: We strive to build an incredibly high quality product. As we’ve worked through the backlog of bugs on Bug Day, we’ve started to look at bringing in the small workflow improvements that add up to big improvements but often get overlooked for bigger changes. This gives us the opportunity to continue to work closely across teams while showing customers that their experience is improving all the time.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><strong>Design Partner programs:</strong> We built our core product with a group of amazing customers who have agreed to give us early feedback and beta test new features, called Design Partners. As we grow, we’re continuing to build groups of Design Partners who can help us create the tools that they need to see their business grow.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">If you’re interested in learning from customers every day in ways like this and more, we’re hiring across teams! See more at ambrook.com/careers.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/process/bug-day</link>
            <guid isPermaLink="false">73148b90-2366-4e76-a22a-f04bb5d246c3</guid>
            <category><![CDATA[Process]]></category>
            <dc:creator><![CDATA[Paige Wyler]]></dc:creator>
            <pubDate>Thu, 05 Jun 2025 19:06:28 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Rapid Loads for Country Roads: Making Ambrook 30% Faster With OpenTelemetry]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/8f0b0842bef8672bbff4bffed3ee4bbb5de95cca-2400x1254.jpg?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">At <a href="https://ambrook.com">Ambrook</a>, we’re building a comprehensive financial management system for farmers — the stewards of our most vital natural resources. As a product for farmers across the United States, we’re serving a customer base constantly on the move, using network connections of varying quality on a wide array of devices. Our customers trust us with their operations, and we have an obligation to provide a product that lives up to their expectations. And when it comes to financial software, performance and reliability are non-negotiable.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Incidents of inconsistent or delayed data or slow interactions across our web and native applications can impact customer trust. To gain better visibility beyond basic metrics and ensure the reliability our expanding user base expects, we chose to adopt <a href="https://opentelemetry.io/">OpenTelemetry</a> for full tracing of our platform.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Ambrook’s systems are entirely in TypeScript, and rely heavily on open-source software. Our application is a Next.js monolith, and to support our React Native mobile app we serve all data to clients via a GraphQL API, powered by Apollo Server. Our data primarily lives in Firestore, though we’re in the process of migrating to PostgreSQL. This stack serves us well, and we had hoped that being built on open-source, battle-tested technologies would ease our tracing implementation.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Ultimately this was less straightforward than we had hoped; we encountered a number of challenges building out our tracing data. However, overcoming these challenges allowed us to improve the performance of our application by 30%, giving our customers a responsive experience they can trust.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Challenge #1: Instrumenting Apollo Server</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Once we instrumented basic request tracing with the <a href="https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node">@opentelemetry/auto-instrumentations-node</a> package, the next challenge was instrumenting Apollo Server. While OpenTelemetry has a GraphQL auto-instrumentation, when deployed in production our GraphQL tracing data had disappeared.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This particular auto-instrumentation patches specific JS files in the <code>graphql</code> npm package, which are not available in production because Next.js bundles our backend code into optimized bundles. Thus, the specific files in that package are not available in the filesystem and no GraphQL operations were instrumented properly.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We were ultimately unable to find an open-source implementation of a runtime GraphQL tracing solution, so we wrote a custom <a href="https://www.apollographql.com/docs/apollo-server/integrations/plugins">Apollo Server plugin</a> to create spans for each Query or Mutation. We use Apollo’s lifecycle hooks to create an active span at the start of each query, and end it when the query finishes resolving.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This instrumented our queries, but didn’t give us much visibility into the cost of each component of our query execution. We paired this plugin with code that wraps every resolver in our schema with a span, to give our traces high-fidelity data about each GraphQL query’s execution.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="Code block for traceResolver.ts" loading="lazy" width="1512" height="883" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/4719eb44720c9818335802280fc7edc0f039871d-1512x883.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">With our custom Apollo tracing in place, we had a clear understanding of high-level performance, and creating custom spans with the OpenTelemetry SDK allowed us to generate finer-grained spans for internal codepaths. The last piece of the puzzle was tracing our database to understand where we had slow queries or were executing too many database operations.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Challenge #2: Database Tracing</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Tracing our database operations presented its own set of challenges. We use both Firestore and PostgreSQL, each requiring different instrumentation approaches. PostgreSQL was easy to instrument, thanks to an existing instrumentation package for <code>postgres.js</code> which worked out of the box.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">While the official Firestore SDK <em>does</em> include tracing events, they’re incredibly high-volume and unfortunately have limited useful data attached, such as the specific collection being queried. We chose to write our own wrapper around the Firestore client that created spans for each database operation. Fortunately, since each of our database collections passes through a shared ORM-like abstraction, this proved fairly straightforward. We simply wrapped every Firestore operation with tracing code similar to our Apollo resolver code:</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="Code block for traceStorageOperation.ts" loading="lazy" width="1512" height="812" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/824bee60e3c665e44700cb360e8ab6007e366616-1512x812.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Challenge #3: Client Tracing</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As a team, we made the decision to not build any infrastructure to link backend traces to client traces. Given our backend is fully isolated from our frontend via our GraphQL API, a full-stack trace for our app is unnecessary, and would likely produce traces so overwhelming they’d be difficult to grok and consume. Instead, we implemented separate client and server tracing to allow us to optimize the runtime user experience separately from our backend performance and reliability.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Tracing client code is deceptively hard, as OpenTelemetry’s browser instrumentation was not designed for modern client-side applications which comprise more than a static HTML file. Because modern applications have minimal markup in their HTML file by default, markup is mostly hydrated in JavaScript, and API requests are executed outside the scope of the initial HTML page load, traditional client tracing misses most of the interesting parts of loading Ambrook.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Instead we built more custom instrumentation. We hijacked the basic OpenTelemetry browser instrumentation and introduced a concept of a “session” on top of it, which starts when the user opens the page and completes when the page’s main content is rendered. This gives us visibility into the full lifecycle of loading JavaScript bundles, rendering, and executing API requests that make up a full client-side page load.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="Diagram of Ambrook's stack consisting of Next.js, Honeycomb, GraphQL, Firestore, and PostgreSQL" loading="lazy" width="1500" height="1000" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/d1e4af55a46b32097f11b48add9e34a24666f077-1500x1000.jpg?w=3840&q=75&fit=clip&auto=format"/></div></figure><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Picking a Storage Provider</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Picking a provider to store and serve our tracing data proved tricky. Many providers that would allow us to send a high volume of data had very restrictive limits on the amount and types of data we could send. As a startup with a wide distribution of customer needs, the ability to track granular attributes like account IDs within our tracing data is critical. In addition to traces, we also need this platform to support timeseries metrics for data that isn’t well-suited to tracing. Lastly, we need the ability to run complex queries across spans to build dashboards and track <a href="https://sre.google/sre-book/service-level-objectives/">SLOs</a> for our platform.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"><a href="https://honeycomb.io">Honeycomb</a> ultimately emerged as our preferred option. Honeycomb encourages “wide” events, allowing up to 2,000 attributes on each span in a trace, with no limits on unique values. Their pricing structure simply prices by event, meaning we can send as much data as we want for a fixed cost. Most importantly, timeseries metrics in Honeycomb are just “events,” so they fit nicely into the billing, and their advanced query and visualization capabilities allow us to dive deep into traces, performance problems, and error rates. For just $130 per month we are able to send 100 million events with near-zero operational cost, and perform important tasks like alerting on performance changes and defining team-wide SLOs.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">The Payoff: Real-Time Insights</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Despite the challenges, the payoff has been substantial. We now have unprecedented visibility into our application’s behavior. We can see exactly what happens when a customer interacts with our application, how that interaction flows through our system, and where any bottlenecks or errors occur.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This visibility has already helped us identify and fix several issues that were previously difficult to reproduce or understand. We discovered certain GraphQL queries which were triggering unnecessary database reads, allowing us to save over 10 million unnecessary reads from Firestore each month. We learned about several N+1 query problems in our financial reporting product, reducing the time required to generate financial statements by 85 percent. Lastly, we were able to uncover several smaller data duplication issues in core APIs that reduced our 90th percentile query duration for queries outside of our reporting product from 0.9 seconds to 0.7 seconds.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">With new tracing in place and increased visibility into the exact set of operations that make up the most complicated parts of our product, these issues were immediately visible and actionable to the team, allowing us to serve our customers’ needs better than ever before. All of these improvements have compounded to improve overall application performance more than 30 percent, and our newly improved ability to dive deep into errors has helped solve even more issues that would have otherwise gone unnoticed.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">If you want to improve the experience of your application, the first step is having the right data to do so. Gathering detailed data about your systems enables a data-driven approach to ensuring your customers see a product you can be proud of.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/infrastructure/30-percent-faster-with-opentelemetry</link>
            <guid isPermaLink="false">e0151870-6bd4-4936-8e3e-b3aca2e6b6c6</guid>
            <category><![CDATA[Infrastructure]]></category>
            <dc:creator><![CDATA[Adam Markon]]></dc:creator>
            <pubDate>Fri, 16 May 2025 17:23:51 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Building a Cross-Platform Web & Mobile App from the Ground Up]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/159ff8269cc189ec516d66b2358c7da8af90832a-2000x1050.png?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">“Why can’t we just write this code once and have it just work across Web, Android and iOS?” As a mobile engineer who has spent both a significant portion of his career copying the exact same product from iOS to Android, I’ve asked myself this question many times.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">At Ambrook, we’re lucky to have the opportunity to approach the challenge of supporting multiple platforms without the constraints of legacy engineering decisions. As a result, we’ve been able to share code across web and mobile, allowing all of our entire engineering team (currently four engineers) to contribute with little overhead.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Ambrook is building a suite of financial tools for farmers that needs to be consistent and fully-featured across desktop and mobile. So that farmers can use our tools in the field, they must work well on slower internet connections and while offline. As an early stage company, we are prioritizing rapid delivery of new features as we get feedback from our users.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">The Project</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">When I first joined the Ambrook team, I was tasked with figuring out how to translate our existing web app to mobile. The web app was written in Typescript using React DOM and Next.js, which made it a tempting target to try using React Native. If successful, using React Native would mean that we could share code between platforms, greatly reducing both the cost of copying existing features and (more importantly) the cost of every new feature we’d need to build going forward.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">At the same time, we wanted to be humble about the reasons why other companies have struggled to adopt cross-platform technologies. ​​A common issue that we avoided is the need to work with existing native product code, a requirement that can significantly negate the productivity advantages of using a cross-platform framework.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">There were three main questions that we set out to address from the beginning:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Can we define best practices and patterns around cleanly separating presentation and business logic to allow for the inevitable UI divergence between platforms?</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Do third-party libraries exist that cover common app needs (ex. navigation, API access, graphing), and do they work well across platforms?</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Does working cross-platform significantly speed up development time compared to just writing the code twice? Is the system easy for someone familiar with our existing codebase to be productive in?</p></li></ol><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We decided to leverage React Native as a compatibility layer between the shared business logic from our existing React (Web) codebase and platform-specific APIs. React Native allows us to bridge the differences between the UI APIs on each platform: DOM on Web, UIKit on iOS, and Android’s View system. We chose to adopt this cross-platform approach because it lets us share business logic between platforms, leverage high quality open source libraries, and rapidly develop and QA new changes to the app.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1732" height="1344" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/e0570db284838ca0af7d549bd5cbe489850af4a2-1732x1344.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe">We decided to leverage React Native as a compatibility layer between the shared business logic from our existing React (Web) codebase and platform-specific APIs.</span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Sharing Business Logic</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">One of the main benefits of our cross-platform strategy is that we are able to share business logic between web and mobile. In fact, <strong>all major screens in our app share the same business logic</strong> across platforms; it was easier to adapt the existing Typescript-based business logic to be platform agnostic than to rewrite the same business logic multiple times.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">An area where we chose not to share code is in the UI layer above the business logic. The guiding principle that we used was to share as much code as practical (business logic and some views) while allowing for some level of divergence to allow for native-feeling UIs and to use the APIs and technologies that felt best for each platform.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">On the web front, this means using CSS-based media queries to create a responsive website and using CSS for things like sticky headers. On mobile, we add support for pull to refresh and double tapping on the navigation bar to scroll to the top of a screen. For both platforms, the lowest level components (like buttons, forms, etc.) are implemented separately as part of our design system, which we talked about in a <a href="https://ambrook.com/blog/how-engineers-design-full-stack-design-systems-at-ambrook">previous post</a>.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Leveraging Open Source Libraries</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">By using React Native, we’re also able to tap into a rich open source ecosystem that has largely removed the need for us to write platform specific native code. Our experience has been that most common app use cases have mature, well-maintained libraries.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Some of our favorite libraries in use are:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://www.apollographql.com/docs/react/">Apollo Client</a>, a GraphQL client implementation with support for optimistic mutations, cache normalization and persistence, and client-side state. Apollo scales well from a limited persistence web environment to an offline, stateful mobile environment.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://formidable.com/open-source/victory/docs/victory-chart/">Victory</a>, a charting library with support for many different chart types.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://reactnavigation.org/">React Navigation</a>, a mobile navigation library with support for tabbed navigation, modals, and navigation stacks. It also has first class URL handling, which makes bridging the gap with web very simple: just use URL-based navigation everywhere.</p></li></ul><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1788" height="820" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/039146b9f93560e816faa36ea6f3358e12e73c7d-1788x820.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe">Using Victory as a charting library allows us to render nearly identical charts across different platforms with minimal effort.</span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Rapid Local Development</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Making a small code change and having it be reflected on device can take anywhere from a few seconds to several minutes in the largest apps. Reducing this incremental build time pays dividends beyond simple time savings. As the iteration time gets shorter, we’re able to both make more rapid and isolated code changes, greatly enhancing comprehension of the code that those changes are being applied to.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Both our web app (via Next.js) and our mobile app (via React Native) support <a href="https://reactnative.dev/docs/fast-refresh">Fast Refresh</a>, allowing changes to components to be reflected in a matter of seconds without losing either app or individual component state. Fast Refresh has been indispensable. When I’m not sure of how a UI will look, I can save small changes several times in a row, tweaking one layout property until everything looks right. Frequently, I keep a browser window and simulator open side by side to reflect live changes on two platforms simultaneously.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Rapid QA on Pull Requests</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As an early-stage company, we frequently ship new features and fixes to our app. In order to prevent new bugs from being introduced during this process, we made it easy for developers to manually test changes as a normal part of their review flow.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">On web, we deploy every open pull request to a unique Google Cloud Run URL so that opening the new version of the app is a single click away.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1498" height="324" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/7cf15440903ce3d649af81c9992c8693f279595f-1498x324.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">On mobile, we generate a QR code that can be scanned by a phone running the Ambrook app to download and run the latest Javascript bundle live on the phone. Native code changes can’t be previewed using this system, but it’s worked well for us because most of our changes do not involve native code. The technical approach used here is similar to that of Shopify’s React Native app, which they’ve described in <a href="https://shopify.engineering/tophatting-react-native">this blog post</a>.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="1502" height="926" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/017333511530bdbf61ae6a76791063990ea6a09f-1502x926.png?w=3840&q=75&fit=clip&auto=format"/></div><figcaption class="sc-c2f48119-0 jxZVyv sc-831aa11f-0 iNZHMk"><span variant="small" colorOverride="#60564C" component="span" class="sc-8f343995-0 gFDqSe">Thanks to Expo for powering the experience shown here.</span><span variant="small" colorOverride="#60564C" noWrap="false,true" component="span" class="sc-8f343995-0 vajye"></span></figcaption></figure><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Our Takeaways</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">For Ambrook, building a cross-platform web and mobile app from a single codebase has saved us significant time and effort. Other teams considering a similar approach can take a few points away from our experience:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">You can expect near-full sharing of business logic between platforms, but having divergent UIs (either at a design system level or at an entire screen level) may be most practical and preferable to create an app that feels native on each platform.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Open source libraries currently cover most common app needs and effectively abstract away platform differences, removing the need to write native code for feature development.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">The developer experience improvements in React Native (Fast Refresh and QA via QR codes) are significant and would be difficult to accomplish in native due to its use of statically compiled languages.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Finally, as a quick catch-all, a few bonus recommendations for adopting our stack:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We found it very helpful to use <a href="https://github.com/infinitered/ignite">boilerplate</a> <a href="https://github.com/mcnamee/react-native-starter-kit">apps</a> when starting, both to understand solutions for common issues (global state management, i18n, etc) and to reduce initial setup time.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Although most engineers on this stack don’t need to know about the underlying iOS / Android systems, you’ll still need someone with a working knowledge of the native build systems and IDEs in practice to debug build and configuration issues. This is something that <a href="https://docs.expo.dev/guides/config-plugins/">the Expo community is working on</a>.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Not all React libraries work well across both native and web, so you’ll need to be deliberate when choosing which to use.</p></li></ul><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Conclusion</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Thanks to our lack of legacy code, we’ve been able to pick the best of breed tools for our situation: React Native on Mobile and React/Next.js on Web. This technology is instrumental in our ability to rapidly and efficiently iterate on our mission of helping to make farmers more profitable and sustainable.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/infrastructure/building-a-cross-platform-web-and-mobile-app-from-the-ground-up</link>
            <guid isPermaLink="false">979de973-d52a-459f-8d78-759dc407e5ec</guid>
            <category><![CDATA[Infrastructure]]></category>
            <dc:creator><![CDATA[Brian Shin]]></dc:creator>
            <pubDate>Mon, 31 Jan 2022 21:55:19 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[How Engineers Design: Full-Stack Design Systems at Ambrook]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/e75ac810eababac7f3a9660ff073943b81de9df6-2000x1050.png?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">As an early-stage company, it’s imperative that we build and test ideas quickly. And, with limited engineering and design resources, we want to be able to build high quality interfaces without robust design speccing or redlining each iteration. To accomplish this, we decided to invest early in a strong design system foundation, not just in our design tools, but also in code.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Investing early in our design system and maintaining parity between Figma and code base enables our engineering team to autonomously and meaningfully contribute to our product without needing a pixel-perfect mockup in every situation.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">All of Ambrook’s products, from our marketing website to our Funding Library, cards and bookkeeping tools are built with these same components, written once by engineers then used everywhere. We’ve built our design system on a single design stack:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://figma.com">Figma</a> components with variants for design</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://reactjs.org/">React</a>, <a href="https://www.styled-components.com/">Styled Components</a>, and <a href="https://mui.com/">Material UI</a> on web</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif"><a href="https://storybook.js.org/">Storybook</a> for visual testing</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">When used in concert, these design system components let us plug together high quality user interfaces without engineers having to worry about things not looking right.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">It all begins in Figma</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Ambrook uses Figma component variants to design consistent interfaces. With components, we can have a central style that gets reused everywhere. And, with Figma variants, we spec out what each possible combination of properties looks like, and then can select the size, color, lightness, hover state, etc from the sidebar easily. This means that when engineers see the button component, they know that it’s the same one that we have in code.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="5344" height="3054" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/6225c7b78f77dd2e8df4a2bfb05adbb59af5b5e9-5344x3054.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">When constructing UIs in Figma, all we need to do is customize the properties of our components to construct interfaces. We use autolayout, which correlates directly with flexbox in code. When engineers view our Figma projects, they can inspect to see which components are used with what configurations, and translate that directly into React code.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Shared Code and Interfaces</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Basic foundational values like colors are shared across components. Because our frontend, backend, and mobile app are all written in Typescript, these type definitions are shared across our stack.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We separate business logic and design implementation, creating an agnostic UI layer that we can use with multiple implementations per platform. This layer makes it easy for us to remove third-party dependencies like down the line if we need to optimize for performance, for example.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">For each component in Figma, we define a React component that takes on similar properties to those outlined in Figma. For our button, that means <code>size</code>, <code>color</code>, <code>light</code>, <code>disabled</code>, etc. We prefix Ambrook design system components with <code>Am</code>, and we keep them in a <code>frontend/design-system</code> folder.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// frontend/design-system/buttons/AmButton.tsx</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">StyledAmButton</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'./AmButton.style'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#60564C">// ...</span><span>
</span><span></span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">AmButton</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">(</span><span>props</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">AmButtonProps</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">JSX</span><span class="token" style="color:#5F6364">.</span><span class="token property-access maybe-class-name">Element</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#60564C">// ...</span><span>
</span><span>  </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">(</span><span>
</span><span>    </span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">StyledAmButton</span><span> </span><span class="token spread" style="color:#60564C">...</span><span> </span><span class="token" style="color:#60564C">/</span><span class="token" style="color:#60564C">></span><span>
</span><span>  </span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token maybe-class-name">AmButton</span><span class="token" style="color:#5F6364">;</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This allows us to write easy-to-read code:</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-jsx" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#5F6364"><</span><span class="token" style="color:#698996">AmButton</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">size</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">large</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">color</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">blue</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">href</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">/careers</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">light</span><span class="token" style="color:#5F6364">></span><span class="token plain-text">
</span><span class="token plain-text">  Click Me
</span><span class="token plain-text"></span><span class="token" style="color:#5F6364"></</span><span class="token" style="color:#698996">AmButton</span><span class="token" style="color:#5F6364">></span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The <code>AmButton</code> component is responsible for any business logic, like support for situations where the caller passed an <code>href</code> (e.g. a button that is actually a link), an <code>onClick</code> prop (e.g. a button that submits a form), both (e.g. a button that is a link but also logs to Segment in the background), or neither (e.g. a button nested in a card which doesn’t actually do anything but passes the click event to its parent). By supporting all the different possible use cases for what a “button” can do in a single <code>AmButton</code> component, we ensure that all buttons look the same, regardless of what clicking them does.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Importantly, the <code>AmButton</code> component isn’t responsible for presentation. We have per-platform implementations, using <a href="https://www.styled-components.com/">Styled Components</a> and <a href="https://mui.com/">Material UI</a> on web, and <a href="https://reactnativepaper.com/">React Native Paper</a> for iOS and Android. We store these implementations in neighboring <code>.style.tsx</code> and <code>.style.native.tsx</code> files:<br/></span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// frontend/design-system/buttons/AmButton.style.tsx</span><span>
</span>
<span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports maybe-class-name">Colors</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'styles/colors'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#60564C">//..</span><span>
</span><span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">StyledAmButton</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#536651">styled</span><span class="token" style="color:#5F6364">(</span><span class="token maybe-class-name">Button</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">StyledAmButtonProps</span><span class="token" style="color:#60564C">></span><span class="token template-string template-punctuation" style="color:#536651">`</span><span class="token template-string" style="color:#536651">
</span><span class="token template-string" style="color:#536651">  background-color: </span><span class="token template-string interpolation interpolation-punctuation" style="color:#5F6364">${</span><span class="token template-string interpolation" style="color:#5F6364">(</span><span class="token template-string interpolation" style="color:#5F6364">{</span><span class="token template-string interpolation"> $color</span><span class="token template-string interpolation" style="color:#5F6364">,</span><span class="token template-string interpolation"> $light </span><span class="token template-string interpolation" style="color:#5F6364">}</span><span class="token template-string interpolation" style="color:#5F6364">)</span><span class="token template-string interpolation"> </span><span class="token template-string interpolation arrow" style="color:#60564C">=></span><span class="token template-string interpolation"> $light
</span><span class="token template-string interpolation">    </span><span class="token template-string interpolation" style="color:#60564C">?</span><span class="token template-string interpolation"> </span><span class="token template-string interpolation maybe-class-name">Colors</span><span class="token template-string interpolation" style="color:#5F6364">[</span><span class="token template-string interpolation">$color</span><span class="token template-string interpolation" style="color:#5F6364">]</span><span class="token template-string interpolation" style="color:#5F6364">[</span><span class="token template-string interpolation" style="color:#BE5557">10</span><span class="token template-string interpolation" style="color:#5F6364">]</span><span class="token template-string interpolation">
</span><span class="token template-string interpolation">    </span><span class="token template-string interpolation" style="color:#60564C">:</span><span class="token template-string interpolation"> </span><span class="token template-string interpolation maybe-class-name">Colors</span><span class="token template-string interpolation" style="color:#5F6364">[</span><span class="token template-string interpolation">$color</span><span class="token template-string interpolation" style="color:#5F6364">]</span><span class="token template-string interpolation" style="color:#5F6364">[</span><span class="token template-string interpolation" style="color:#BE5557">50</span><span class="token template-string interpolation" style="color:#5F6364">]</span><span class="token template-string interpolation interpolation-punctuation" style="color:#5F6364">}</span><span class="token template-string" style="color:#536651">;
</span><span class="token template-string" style="color:#536651">  // ...
</span><span class="token template-string" style="color:#536651"></span><span class="token template-string template-punctuation" style="color:#536651">`</span><span class="token" style="color:#5F6364">;</span><span>
</span>
</code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">The main component file is then responsible for rendering a <code>StyledAmButton</code>. The props prefixed with a <code>$</code> will not be passed to the HTML, and will just be used within the styled component itself.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-jsx" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#5F6364"><</span><span class="token" style="color:#698996">StyledAmButton</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">$size</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">large</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">$color</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">blue</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#536651">href</span><span class="token attr-equals" style="color:#5F6364">=</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#698996">/careers</span><span class="token" style="color:#5F6364">"</span><span class="token" style="color:#BE5557"> </span><span class="token spread" style="color:#5F6364">{</span><span class="token spread" style="color:#60564C">...</span><span class="token spread" style="color:#BE5557">moreProps</span><span class="token spread" style="color:#5F6364">}</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#5F6364">/></span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">For some design system components, we build them directly from <code>View</code> components or HTML elements. For more complex ones, we wrap Material UI and React Native Paper components, which offer robust style implementations like ripples and animations, as well as accessibility features out-of-the-box.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Visualizing in Storybook</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">In the examples above, we’ve been using a simplified version of our components. In reality, <code>AmButton</code> takes over 20 different props! As components become more complex, being able to verify how components are supposed to look and work in all of their variants can be difficult. To make this easier, we’re starting to use <a href="https://storybook.js.org/">Storybook</a>.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Storybook plugs into the type definitions for our components, and creates properties and controls that can be played with in their pre-built UI.</span></p><figure class="sc-c2f48119-0 bJkOxo"><div><img alt="" loading="lazy" width="2052" height="1400" decoding="async" data-nimg="1" class="sc-34db8b60-0 hzsIkj" style="color:transparent;height:auto;width:100%" sizes="(min-width: 1024px) 46rem, (min-width: 768px) 46rem, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/13ca149d6c75f0278a148865dc5dfda2eecdfc10-2052x1400.png?w=3840&q=75&fit=clip&auto=format"/></div></figure><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We can define stories with just a few lines of code, which reduces the maintenance effort to near-zero. We store our stories next to our components, as a neighboring <code>.stories.tsx</code> file. Here’s what our button stories look like:</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// frontend/design-system/buttons/AmButton.stories.tsx</span><span>
</span>
<span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports maybe-class-name">AmButton</span><span class="token imports" style="color:#5F6364">,</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">AmButtonProps</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'frontend/design-system/buttons/AmButton'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">Meta</span><span class="token imports" style="color:#5F6364">,</span><span class="token imports"> </span><span class="token imports maybe-class-name">Story</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'@storybook/react/types-6-0'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports maybe-class-name">React</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'react'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">Normal</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">Story</span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">AmButtonProps</span><span class="token" style="color:#60564C">></span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">(</span><span>args</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">(</span><span>
</span><span>  </span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">AmButton</span><span> </span><span class="token" style="color:#5F6364">{</span><span class="token spread" style="color:#60564C">...</span><span>args</span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#60564C">></span><span class="token maybe-class-name">Hello</span><span> </span><span class="token maybe-class-name">World</span><span class="token" style="color:#60564C"><</span><span class="token" style="color:#60564C">/</span><span class="token maybe-class-name">AmButton</span><span class="token" style="color:#60564C">></span><span>
</span><span></span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">WithStartIcon</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token maybe-class-name">Normal</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">bind</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">{</span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token maybe-class-name">WithStartIcon</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">args</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">{</span><span> </span><span class="token spread" style="color:#60564C">...</span><span class="token maybe-class-name">Normal</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">args</span><span class="token" style="color:#5F6364">,</span><span> startIcon</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">Decagram</span><span> </span><span class="token" style="color:#60564C">/</span><span class="token" style="color:#60564C">></span><span> </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">WithEndIcon</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token maybe-class-name">Normal</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">bind</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">{</span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token maybe-class-name">WithEndIcon</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">args</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">{</span><span> </span><span class="token spread" style="color:#60564C">...</span><span class="token maybe-class-name">Normal</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">args</span><span class="token" style="color:#5F6364">,</span><span> endIcon</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">ArrowRight</span><span> </span><span class="token" style="color:#60564C">/</span><span class="token" style="color:#60564C">></span><span> </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  component</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">AmButton</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>  title</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#536651">'Components/AmButton'</span><span class="token" style="color:#5F6364">,</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">as</span><span> </span><span class="token maybe-class-name">Meta</span><span class="token" style="color:#5F6364">;</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Our storybook components allow us to play around with the possible properties that can be provided to a component, and see how they interact.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Moving Fast</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Bringing our design system into code takes time up front, as well as maintenance along the way. But so far, our investment has paid off, letting us experiment and iterate on our product without worrying about getting the design details right.<br/></span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/design-systems/how-engineers-design-full-stack-design-systems-at-ambrook</link>
            <guid isPermaLink="false">61f29a50-326d-4b66-bded-0e9429a24df7</guid>
            <category><![CDATA[Design Systems]]></category>
            <dc:creator><![CDATA[Dan Schlosser]]></dc:creator>
            <pubDate>Thu, 23 Dec 2021 20:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Ambrook’s Seamless Authentication with Next.js and Firebase]]></title>
            <description><![CDATA[<div class="sc-c2f48119-0 bJkOxo"><div class="sc-c06bedfd-8 cYRPgp"><img alt="" loading="lazy" decoding="async" data-nimg="fill" class="sc-34db8b60-0 hzsIkj" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" sizes="(min-width: 1024px) 100vw, (min-width: 768px) 100vw, 100vw" srcSet="https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=640&q=75&fit=clip&auto=format 640w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=750&q=75&fit=clip&auto=format 750w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=768&q=75&fit=clip&auto=format 768w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=828&q=75&fit=clip&auto=format 828w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1080&q=75&fit=clip&auto=format 1080w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1104&q=75&fit=clip&auto=format 1104w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1200&q=75&fit=clip&auto=format 1200w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1280&q=75&fit=clip&auto=format 1280w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=1920&q=75&fit=clip&auto=format 1920w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=2048&q=75&fit=clip&auto=format 2048w, https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=3840&q=75&fit=clip&auto=format 3840w" src="https://cdn.sanity.io/images/h6kk644c/production/b48baa2b79752e9bd6e6b0a7669b73f5d8995403-2000x1050.png?w=3840&q=75&fit=clip&auto=format"/></div></div><div><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">When I started working on <a href="https://schlosser.io/teams/ambrook">Ambrook</a>, adopting <a href="https://nextjs.org/">Next.js</a> was a clear choice. I liked that it flexibly combines statically generated (SSG) pages (like blog posts, marketing sites), with server-side rendered (SSR) pages (like our app and account pages), and merges it all together with a smart client-side navigation scheme.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0"><a href="https://firebase.google.com/docs/auth">Firebase Authentication</a> has been my go-to authentication choice for years. It handles all of the complexities of managing multiple authentication methods (password, Facebook, Google, phone number, magic link) into a single account system.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">In using both, I hoped to create a best-in-class user experience. Here’s what I wanted:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Server-side rendered private pages that load the user’s data without lots of spinners.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Statically generated public pages, that show the same content for all users.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Cookie-based authentication that lets users start to input their information and use our app before being asked to make an account, saving progress as they go.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Server-side redirects for users that load a private page without being logged in.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Client-side redirects for users that click an onsite link to a private page without being logged in.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Furthermore, I had some developer experience requirements:</span></p><ul><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Make it easy to introduce new pages and API routes that are auth-guarded.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Reduce the likelihood that private pages are accidentally exposed.</p></li></ul><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">While the basic integration was straightforward, the combination of these two tools created a fair number of cases to consider. Next.js lets users access pages by client side navigation and server side navigation. Firebase Authentication issues ID tokens that only last for an hour, after which they need to be refreshed using the client-side SDKs.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Authentication and SSR</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Authenticating pages accessed via client side navigation is easy – the Firebase SDK will log users in automatically if they have an account, and private pages can check the <code>currentUser</code>. But for the initial HTTP request, we’ll need to use a cookie to store the user’s <a href="https://firebase.google.com/docs/auth/admin/verify-id-tokens">ID token</a> to be verified on the server. Here’s how it works: </span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">First, we sign in the user using the Javascript SDK:</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#698996">const</span><span> </span><span class="token" style="color:#5F6364">{</span><span> user </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">auth</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">signInWithCredential</span><span class="token" style="color:#5F6364">(</span><span>cred</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Then, on the client, we generate an ID token and write it to the user’s cookies.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">cookie</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'js-cookie'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> </span><span class="token function-variable" style="color:#536651">persistUserCredential</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">(</span><span>user</span><span class="token" style="color:#60564C">:</span><span> firebase</span><span class="token" style="color:#5F6364">.</span><span class="token property-access maybe-class-name">User</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#698996">const</span><span> token </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> user</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">getIdToken</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  cookie</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">set</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#536651">'token'</span><span class="token" style="color:#5F6364">,</span><span> token</span><span class="token" style="color:#5F6364">,</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    expires</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">1</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>    path</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#536651">'/'</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Finally, when the user next requests an SSR-powered page, the cookies are passed along in the request headers. We can then verify the ID token to validate a user’s identity.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// Some code is missing here, I'll go into more detail later.</span><span>
</span><span></span><span class="token" style="color:#698996">const</span><span> token </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#536651">getCookie</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#536651">'token'</span><span class="token" style="color:#5F6364">,</span><span> ctx</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">req</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">headers</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#698996">const</span><span> </span><span class="token" style="color:#5F6364">{</span><span> uid</span><span class="token" style="color:#5F6364">,</span><span> email </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">verifyIdToken</span><span class="token" style="color:#5F6364">(</span><span>token</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span></code></pre><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Maintaining State When Users Refresh</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">We want users to save our user’s progress before they enter their email address, and connect their saved progress with a full account later if they wish. To do this, we’re using Firebase’s <a href="https://firebase.google.com/docs/auth/web/anonymous-auth">authentication method built for this purpose</a>. To integrate this into Next.js, we modify our custom <code>_app.tsx</code>. Here’s how it works:</span></p><ol><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">Every time a page renders on the client, our <code>_app.tsx</code>‘s render function will run. We use the <code>useEffect</code> hook to run our setup code on the first render only.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We set up a listener to changes in the Firebase Authentication state using <code>firebase.auth.onAuthStateChanged</code>, which takes a callback that contains the current user. It runs first with <code>null</code>, and then once Firebase has loaded the logged in user from the local session.</p></li><li><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" variant="body" family="serif">We either persist the user’s credentials to the cookie, if they’re logged in, or log them in anonymously if they are not.</p></li></ol><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-jsx" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// pages/_app.tsx</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">AppProps</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'next/app'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token maybe-class-name">React</span><span class="token" style="color:#5F6364">,</span><span> </span><span class="token" style="color:#5F6364">{</span><span> </span><span class="token" style="color:#BE5557">FC</span><span class="token" style="color:#5F6364">,</span><span> useEffect </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'react'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">firebase</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'firebase/app'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">persistUserCredential</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'utils/auth/persistUserCredential'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">MyApp</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">FC</span><span class="token" style="color:#5F6364"><</span><span class="token" style="color:#698996">AppProps</span><span class="token" style="color:#5F6364">></span><span class="token plain-text"> = (</span><span class="token" style="color:#5F6364">{</span><span> </span><span class="token maybe-class-name">Component</span><span class="token" style="color:#5F6364">,</span><span> pageProps </span><span class="token" style="color:#5F6364">}</span><span class="token plain-text">: AppProps): JSX.Element => </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#60564C">// ...</span><span>
</span><span>  </span><span class="token" style="color:#536651">useEffect</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">return</span><span> firebase</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">auth</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">onAuthStateChanged</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">(</span><span class="token parameter">user</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">if</span><span> </span><span class="token" style="color:#5F6364">(</span><span>user</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>        </span><span class="token" style="color:#60564C">// If the user just signed in, we call the code to create the cookie.</span><span>
</span><span>        </span><span class="token" style="color:#536651">persistUserCredential</span><span class="token" style="color:#5F6364">(</span><span>user</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>      </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token control-flow" style="color:#698996">else</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>        firebase</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">auth</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span>
</span><span>          </span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">signInAnonymously</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span>
</span><span>          </span><span class="token" style="color:#5F6364">.</span><span class="token control-flow" style="color:#698996">catch</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#698996">function</span><span> </span><span class="token" style="color:#5F6364">(</span><span class="token parameter">error</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>            </span><span class="token" style="color:#60564C">// Handle Errors here.</span><span>
</span><span>          </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>      </span><span class="token" style="color:#5F6364">}</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">,</span><span> </span><span class="token" style="color:#5F6364">[</span><span class="token" style="color:#5F6364">]</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#60564C">// ...</span><span>
</span><span>  </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364"><</span><span class="token" style="color:#698996">Component</span><span class="token" style="color:#BE5557"> </span><span class="token spread" style="color:#5F6364">{</span><span class="token spread" style="color:#60564C">...</span><span class="token spread" style="color:#BE5557">pageProps</span><span class="token spread" style="color:#5F6364">}</span><span class="token" style="color:#BE5557"> </span><span class="token" style="color:#5F6364">/></span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span class="token plain-text">;
</span><span class="token plain-text">
</span><span class="token plain-text">export default MyApp;
</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Next, we’ll set up the code that verifies token on the server.</span></p><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Handling redirects</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">It’s important that users get redirected to the login page if they’re logged out but trying to access a private resource, no matter if they navigate on the client or directly to a private URL. I use the <code>redirect</code> feature <a href="https://nextjs.org/blog/next-10#redirect-and-notfound-support-for-getstaticprops--getserversideprops">introduced in Next.js 10</a>, which allows <code>GetServerSideProps</code> to return a redirect object that will be executed on the client or server, depending on the user’s context.</span></p><blockquote class="sc-8f343995-0 kHOYll sc-c970a3fe-1 fZpjlK" variant="body" component="blockquote">Initially, I was able to make all of this work using Next.js’s <code>GetInitialProps</code>, which runs when a page is first loaded, on the client on client-side navigation, and on the server on server-side navigation. This method is deprecated in favor of <code>GetServerSideProps</code> however, and not recommended. Read more about <a href="https://nextjs.org/docs/basic-features/data-fetching">data fetching in Next.js</a> and <a href="https://nextjs.org/docs/api-reference/data-fetching/getInitialProps"><code>getInitialProps</code></a>.</blockquote><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">I wrote a wrapper function, <code>withPrivateServerSideProps</code> that wraps a page’s <code>GetServerSideProps</code> function:</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// withPrivateServerSideProps.ts</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">GetServerSideProps</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'next'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">isAuthenticated</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'utils/auth/isAuthenticated'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token doc-comment" style="color:#60564C">/**
</span><span class="token doc-comment" style="color:#60564C"> * This function wraps a page's GetServerSideProps function. It passes the
</span><span class="token doc-comment" style="color:#60564C"> * `redirect` object if the user needs to authenticate, and calls the wrapped
</span><span class="token doc-comment" style="color:#60564C"> * function otherwise.
</span><span class="token doc-comment" style="color:#60564C"> */</span><span>
</span><span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token" style="color:#698996">function</span><span> </span><span class="token generic-function" style="color:#536651">withPrivateServerSideProps</span><span class="token generic-function generic" style="color:#60564C"><</span><span class="token generic-function generic" style="color:#BE5557">P</span><span class="token generic-function generic" style="color:#60564C">></span><span class="token" style="color:#5F6364">(</span><span>
</span><span>  getServerSidePropsFunc</span><span class="token" style="color:#60564C">?</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">GetServerSideProps</span><span class="token" style="color:#5F6364">,</span><span>
</span><span></span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">GetServerSideProps</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#698996">const</span><span> withPrivateSSP</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token function-variable maybe-class-name" style="color:#536651">GetServerSideProps</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#698996">async</span><span> </span><span class="token" style="color:#5F6364">(</span><span>ctx</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    </span><span class="token" style="color:#698996">const</span><span> _isAuthenticated </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">isAuthenticated</span><span class="token" style="color:#5F6364">(</span><span>ctx</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span>    </span><span class="token" style="color:#60564C">// If not authenticated, we return a redirect object that instructs</span><span>
</span><span>    </span><span class="token" style="color:#60564C">// Next.js to redirect to our login page.</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">if</span><span> </span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#60564C">!</span><span>_isAuthenticated</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>        redirect</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>          destination</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token template-string template-punctuation" style="color:#536651">`</span><span class="token template-string" style="color:#536651">/login?redirectTo=</span><span class="token template-string interpolation interpolation-punctuation" style="color:#5F6364">${</span><span class="token template-string interpolation">ctx</span><span class="token template-string interpolation" style="color:#5F6364">.</span><span class="token template-string interpolation property-access">resolvedUrl</span><span class="token template-string interpolation interpolation-punctuation" style="color:#5F6364">}</span><span class="token template-string template-punctuation" style="color:#536651">`</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>          permanent</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">false</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>        </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">,</span><span>
</span><span>      </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span>
</span>
<span>    </span><span class="token control-flow" style="color:#698996">if</span><span> </span><span class="token" style="color:#5F6364">(</span><span>getServerSidePropsFunc</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">getServerSidePropsFunc</span><span class="token" style="color:#5F6364">(</span><span>ctx</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">{</span><span> props</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#5F6364">{</span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span>  </span><span class="token control-flow" style="color:#698996">return</span><span> withPrivateSSP</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This code uses a helper function, <code>isAuthenticated</code>, to determine if the user is authenticated. It uses the Firebase Admin SDK’s <code>verifyIdToken</code> function to validate the token and look up the user’s basic details.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// utils/auth/isAuthenticated.ts</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">GetServerSidePropsContext</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'next'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> getCookie </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'utils/auth/cookies'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token" style="color:#698996">async</span><span> </span><span class="token" style="color:#698996">function</span><span> </span><span class="token" style="color:#536651">isAuthenticated</span><span class="token" style="color:#5F6364">(</span><span>
</span><span>  ctx</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">GetServerSidePropsContext</span><span>
</span><span></span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token known-class-name" style="color:#698996">Promise</span><span class="token" style="color:#60564C"><</span><span class="token" style="color:#536651">boolean</span><span class="token" style="color:#60564C">></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#698996">const</span><span> token </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#536651">getCookie</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#536651">'token'</span><span class="token" style="color:#5F6364">,</span><span> ctx</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">req</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">headers</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span>  </span><span class="token control-flow" style="color:#698996">if</span><span> </span><span class="token" style="color:#5F6364">(</span><span>token</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    </span><span class="token" style="color:#698996">const</span><span> </span><span class="token" style="color:#5F6364">{</span><span> uid</span><span class="token" style="color:#5F6364">,</span><span> email </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">verifyIdToken</span><span class="token" style="color:#5F6364">(</span><span>token</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    </span><span class="token" style="color:#60564C">// An anonymous user may have a UID, but authenticated users must have an</span><span>
</span><span>    </span><span class="token" style="color:#60564C">// account (an email address).</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#60564C">!</span><span class="token" style="color:#60564C">!</span><span>email</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span>
</span>
<span>  </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#BE5557">false</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span></code></pre><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Here, the <code>getCookie</code> function is parsing the <code>cookie</code> header, and either returning the <code>token</code> key or <code>undefined</code>.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">This makes for a very simple integration into a private page, like <code>/account</code>:</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// pages/account.tsx</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">withPrivateServerSideProps</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'hocs/withPrivateServerSideProps'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports maybe-class-name">React</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'react'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> getAccountDetails </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'lib/account'</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports maybe-class-name">Account</span><span class="token imports" style="color:#5F6364">,</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> </span><span class="token imports maybe-class-name">AccountProps</span><span class="token imports"> </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'screens/Account/Account'</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">AccountPage</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">(</span><span>props</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token maybe-class-name">AccountProps</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">JSX</span><span class="token" style="color:#5F6364">.</span><span class="token property-access maybe-class-name">Element</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">(</span><span>
</span><span>    </span><span class="token" style="color:#60564C"><</span><span class="token maybe-class-name">Account</span><span> </span><span class="token" style="color:#5F6364">{</span><span class="token spread" style="color:#60564C">...</span><span>props</span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#60564C">/</span><span class="token" style="color:#60564C">></span><span>
</span><span>  </span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token" style="color:#698996">const</span><span> getServerSideProps </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#536651">withPrivateServerSideProps</span><span class="token" style="color:#5F6364">(</span><span>
</span><span>  </span><span class="token" style="color:#698996">async</span><span> </span><span class="token" style="color:#5F6364">(</span><span>ctx</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">try</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token" style="color:#698996">const</span><span> accountDetails </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token control-flow" style="color:#698996">await</span><span> </span><span class="token" style="color:#536651">getAccountDetails</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">{</span><span> props</span><span class="token" style="color:#60564C">:</span><span> accountDetails </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token control-flow" style="color:#698996">catch</span><span> </span><span class="token" style="color:#5F6364">(</span><span>error</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">{</span><span> props</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#5F6364">{</span><span class="token" style="color:#5F6364">}</span><span> </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">,</span><span>
</span><span></span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token maybe-class-name">AccountPage</span><span class="token" style="color:#5F6364">;</span></code></pre><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Handling expired tokens</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Firebase’s ID tokens expire after about an hour, so it’s likely that users that request a page will need to refresh their token in order to be authenticated. If this is the case, we redirect to the <code>/login</code> page, just like if they were logged out. However, in this case, we detect the presence of an existing token and attempt to refresh it. Here, we use <code>firebase.auth().onIdTokenChanged</code>, which detects not just changes in the user’s logged in status, but also when their ID token is refreshed automatically by Firebase. On the login page, we detect this state, persist their new ID token to the cookie, and redirect them to where they where going.</span></p><pre style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;font-size:1em;text-align:left;white-space:pre;line-height:1.5;word-break:normal;-moz-tab-size:4;word-spacing:normal;-o-tab-size:4;word-wrap:normal;-moz-hyphens:none;-webkit-hyphens:none;hyphens:none;tab-size:4;margin:.5em 0;-ms-hyphens:none;overflow:visible;background-color:#EFE9E0;position:relative;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1.5rem;box-sizing:border-box;margin-bottom:0"><code class="language-typescript" style="background:none;color:#211B15;font-family:Ioskeley Mono, Inconsolata, ui-monospace, SF Mono, Menlo, Consolas, monospace;-moz-tab-size:4;font-size:1em;-o-tab-size:4;line-height:1.75;-moz-hyphens:none;text-align:left;-webkit-hyphens:none;white-space:pre;height:inherit;word-break:normal;display:block;word-spacing:normal;hyphens:none;word-wrap:normal;max-height:inherit;-ms-hyphens:none;overflow:auto;tab-size:4;padding:0"><span class="token" style="color:#60564C">// pages/login.tsx</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">firebase</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'firebase/app'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> useRouter </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'next/router'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports">persistUserCredential</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'utils/auth/persistUserCredential'</span><span class="token" style="color:#5F6364">;</span><span>
</span><span></span><span class="token module" style="color:#698996">import</span><span> </span><span class="token imports" style="color:#5F6364">{</span><span class="token imports"> useEffect </span><span class="token imports" style="color:#5F6364">}</span><span> </span><span class="token module" style="color:#698996">from</span><span> </span><span class="token" style="color:#536651">'react'</span><span>
</span>
<span></span><span class="token" style="color:#698996">const</span><span> </span><span class="token maybe-class-name">LoginPage</span><span> </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">{</span><span>
</span>  redirectUrl
<span></span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#5F6364">{</span><span>redirectUrl</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#536651">string</span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#60564C">:</span><span> </span><span class="token" style="color:#BE5557">JSX</span><span class="token" style="color:#5F6364">.</span><span class="token property-access maybe-class-name">Element</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>  </span><span class="token" style="color:#698996">const</span><span> router </span><span class="token" style="color:#60564C">=</span><span> </span><span class="token" style="color:#536651">useRouter</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span>  </span><span class="token" style="color:#60564C">// ...</span><span>
</span>
<span>  </span><span class="token" style="color:#536651">useEffect</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>    </span><span class="token control-flow" style="color:#698996">return</span><span> firebase</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">auth</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">onIdTokenChanged</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">(</span><span>user</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>      </span><span class="token control-flow" style="color:#698996">if</span><span> </span><span class="token" style="color:#5F6364">(</span><span>user </span><span class="token" style="color:#60564C">&&</span><span> </span><span class="token" style="color:#60564C">!</span><span>user</span><span class="token" style="color:#5F6364">.</span><span class="token property-access">isAnonymous</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>        </span><span class="token" style="color:#536651">persistUserCredential</span><span class="token" style="color:#5F6364">(</span><span>user</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">then</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">(</span><span class="token" style="color:#5F6364">)</span><span> </span><span class="token arrow" style="color:#60564C">=></span><span> </span><span class="token" style="color:#5F6364">{</span><span>
</span><span>          router</span><span class="token" style="color:#5F6364">.</span><span class="token method property-access" style="color:#536651">push</span><span class="token" style="color:#5F6364">(</span><span>redirectUrl</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>        </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>      </span><span class="token" style="color:#5F6364">}</span><span>
</span><span>    </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">,</span><span> </span><span class="token" style="color:#5F6364">[</span><span>redirectUrl</span><span class="token" style="color:#5F6364">,</span><span> router</span><span class="token" style="color:#5F6364">]</span><span class="token" style="color:#5F6364">)</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span>  </span><span class="token control-flow" style="color:#698996">return</span><span> </span><span class="token" style="color:#5F6364">(</span><span>
</span><span>    </span><span class="token" style="color:#60564C">/* login page content */</span><span>
</span><span>  </span><span class="token" style="color:#5F6364">)</span><span>
</span><span></span><span class="token" style="color:#5F6364">}</span><span class="token" style="color:#5F6364">;</span><span>
</span>
<span></span><span class="token module" style="color:#698996">export</span><span> </span><span class="token module" style="color:#698996">default</span><span> </span><span class="token maybe-class-name">LoginPage</span><span class="token" style="color:#5F6364">;</span></code></pre><h2 variant="large,xlarge" weight="bold" component="h2" class="sc-8f343995-0 eLREcy">Lots of cases to handle!</h2><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Between client-side and server side navigation, private and public pages, and the user’s authentication state (logged out, logged in, expired token), there are a lot of cases to handle. But by carefully managing and correctly passing around the ID token, we can allow Next.js to authenticate a user from their ID token in whatever context we find them.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">There were a couple of features we added that I didn’t include here, but they all follow from the structure outlined above. Asynchronous API requests using <code>fetch()</code> do not pass cookies by default, so I had to write a wrapper that includes the ID token in requests to my API routes. Also, we ended up building out “account-enhanced” pages that load basic content for logged out users and more content for logged in users. These pages follow the more traditional SPA model: Load the page and show a spinner, request the authentication state asynchronously, and then load the logged-in content (or not). We also use Redux in our app, but I removed that code from the samples above for clarity.</span></p><p class="sc-8f343995-0 hpyzdn sc-c970a3fe-2 jdRprY" family="serif" variant="body"><span class="sc-3c5057b6-0">Hopefully this guide is helpful for your next project! If you have any questions, feel free to reach out to me <a href="https://twitter.com/danrschlosser">on Twitter</a>.</span></p></div>]]></description>
            <link>https://ambrook-staging.com/engineering/infrastructure/seamless-authentication-with-next-js-and-firebase-auth</link>
            <guid isPermaLink="false">e4ee93d1-897a-4b1a-a0a4-795413a431a7</guid>
            <category><![CDATA[Infrastructure]]></category>
            <dc:creator><![CDATA[Dan Schlosser]]></dc:creator>
            <pubDate>Thu, 04 Mar 2021 05:00:00 GMT</pubDate>
        </item>
    </channel>
</rss>