<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Henry Lee</title>
    <description>The latest articles on DEV Community by Henry Lee (@jobkaehenry).</description>
    <link>https://dev.to/jobkaehenry</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207407%2F5776b98f-06aa-4dd3-97d4-f4ed76ff1b09.jpg</url>
      <title>DEV Community: Henry Lee</title>
      <link>https://dev.to/jobkaehenry</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jobkaehenry"/>
    <language>en</language>
    <item>
      <title>Same-Origin or Bust: Proxying No-Code Pages Through Nginx So Our Analytics Could See Them</title>
      <dc:creator>Henry Lee</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:15:37 +0000</pubDate>
      <link>https://dev.to/jobkaehenry/same-origin-or-bust-proxying-no-code-pages-through-nginx-so-our-analytics-could-see-them-15kf</link>
      <guid>https://dev.to/jobkaehenry/same-origin-or-bust-proxying-no-code-pages-through-nginx-so-our-analytics-could-see-them-15kf</guid>
      <description>&lt;p&gt;We had a nice setup: designers built marketing and feature pages in &lt;strong&gt;Framer&lt;/strong&gt;, and we embedded them into our product. It cut a lot of development time.&lt;/p&gt;

&lt;p&gt;Then we adopted a new analytics tool, and that setup quietly stopped working for it. This post walks through why, the options we weighed, and how we ended up with a dedicated &lt;strong&gt;Nginx reverse proxy&lt;/strong&gt; — after validating the idea in Express first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new requirement: behavior analytics
&lt;/h2&gt;

&lt;p&gt;If you run a product, you know how valuable it is to see where users click and how they move through a page.&lt;/p&gt;

&lt;p&gt;We already used GTM and Hotjar, but we wanted more precise behavioral data, so we adopted &lt;strong&gt;Beusable&lt;/strong&gt;, a tool that visualizes clicks, mouse movement, and scroll flow.&lt;/p&gt;

&lt;p&gt;The catch: it only works inside iframes under strict conditions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tracked page must be &lt;strong&gt;same-origin&lt;/strong&gt; with the host page.&lt;/li&gt;
&lt;li&gt;The embedded page must not send &lt;strong&gt;CSP headers that block its script&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkcnu5pkasrb7hipp9zll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkcnu5pkasrb7hipp9zll.png" alt="Cross-origin iframe vs. same-origin via reverse proxy" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Framer's hosting
&lt;/h2&gt;

&lt;p&gt;Framer hosts every project at &lt;code&gt;project-name.framer.com&lt;/code&gt;. You &lt;em&gt;can&lt;/em&gt; embed that page in an iframe, but it's a different domain — so it's not same-origin, and the analytics tool can't capture any events inside it.&lt;/p&gt;

&lt;p&gt;Missing data for the pages we were actively iterating on meant weaker decisions about how to improve them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Options we considered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A subdomain
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Idea:&lt;/strong&gt; point something like &lt;code&gt;framer.example.com&lt;/code&gt; at Framer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; browsers treat subdomains as different origins too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Same-origin policy:&lt;/strong&gt; if the protocol, host, or port differs, the browser treats it as a different origin and restricts access. &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;framer.example.com&lt;/code&gt; are separate origins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Self-host Framer's export
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Idea:&lt;/strong&gt; export HTML/CSS from Framer and serve it ourselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; designers edit these pages often. Every change would need a manual re-export, which is hard to automate and expensive to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A reverse proxy
&lt;/h3&gt;

&lt;p&gt;A reverse proxy serves external content &lt;em&gt;through our own domain&lt;/em&gt;, which solves the same-origin problem. After talking it through with our backend engineers, we went this way and compared three ways to build it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Load balancer rules at the infra layer.&lt;/strong&gt; Every new proxy route would need an infra engineer. Slow, and hard to scale as a team practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) Middleware in our existing Express server.&lt;/strong&gt; Our React app already ran behind Express for SEO injection, so a proxy middleware would work. But Express was already serving static files and acting as an API server. Adding proxying would pile on complexity and blur responsibilities further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) A dedicated Nginx proxy. ✅&lt;/strong&gt; This is what we chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nginx
&lt;/h2&gt;

&lt;p&gt;Nginx is a lightweight, fast, flexible web server and reverse proxy — good at static files, load distribution, and proxying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drops easily into our existing Docker-based deploy pipeline.&lt;/li&gt;
&lt;li&gt;Pulls a cross-cutting concern out of Express, so responsibilities are clearer.&lt;/li&gt;
&lt;li&gt;Security headers (HSTS, X-Frame-Options, X-Content-Type-Options) are a few lines of config.&lt;/li&gt;
&lt;li&gt;Bot blocking, IP restrictions, and rate limiting are available when needed.&lt;/li&gt;
&lt;li&gt;Gzip and proxy caching come almost for free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One more piece of config to maintain. We judged the benefit worth the added complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Validate fast, then refactor
&lt;/h2&gt;

&lt;p&gt;We wanted two things at once: keep the service stable &lt;em&gt;and&lt;/em&gt; improve the architecture. So we didn't jump straight to Nginx.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, we added reverse-proxy logic &lt;strong&gt;directly in Express&lt;/strong&gt;. That let us confirm the analytics tool worked through a proxy without touching infrastructure.&lt;/li&gt;
&lt;li&gt;Once that was proven, we refactored step by step and &lt;strong&gt;moved the proxy into Nginx&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Prove the effect quickly, then clean up the structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new container layout
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zwovcwb3im37m2qd49a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zwovcwb3im37m2qd49a.png" alt="Container layout before and after" width="800" height="1015"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; the container forwarded external traffic to port 80, where Express answered everything directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; listens on port 80 inside the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express&lt;/strong&gt; moved to an internal port (e.g. 3000).&lt;/li&gt;
&lt;li&gt;Nginx routes by path: proxy to Express, serve static files directly, or proxy to Framer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Serving static files directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/path/to/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;1d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=86400,&lt;/span&gt; &lt;span class="s"&gt;s-maxage=86400"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;gzip_static&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Proxying Framer pages so they're same-origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/service/framer/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;https://your-project.framer.website/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;your-project.framer.website&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_ssl_server_name&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;proxy_intercept_errors&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-XSS-Protection&lt;/span&gt; &lt;span class="s"&gt;"1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;mode=block"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"upgrade-insecure-requests"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Analytics works.&lt;/strong&gt; Framer pages are now served same-origin, so the heatmap tool tracks them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; HTTPS, CSP, and HSTS headers live in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; static files are served by Nginx with caching and gzip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintainability:&lt;/strong&gt; each layer has one job, so problems can be debugged independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What started as "make the analytics tool work" ended as a cleaner, more secure, more observable architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Henry, a frontend engineer focused on React, Next.js, TypeScript and Playwright E2E testing. This post is adapted from a write-up I originally published in Korean. I'm open to frontend roles abroad — let's connect on &lt;a href="https://www.linkedin.com/in/jobkaehenry" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or see my work on &lt;a href="https://github.com/jobkaeHenry" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>analytics</category>
    </item>
    <item>
      <title>We Built Our Own Frontend Take-Home Assignment — and Graded It with Playwright</title>
      <dc:creator>Henry Lee</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:15:36 +0000</pubDate>
      <link>https://dev.to/jobkaehenry/we-built-our-own-frontend-take-home-assignment-and-graded-it-with-playwright-242j</link>
      <guid>https://dev.to/jobkaehenry/we-built-our-own-frontend-take-home-assignment-and-graded-it-with-playwright-242j</guid>
      <description>&lt;p&gt;When teams design a frontend take-home, the first idea is usually a CRUD app. We decided to go one step further.&lt;/p&gt;

&lt;p&gt;This post covers why we designed our assignment as a &lt;strong&gt;simulation of real work&lt;/strong&gt; instead of a pure implementation task, and what we learned building an &lt;strong&gt;E2E auto-grading pipeline&lt;/strong&gt; for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Isn't CRUD enough?"
&lt;/h2&gt;

&lt;p&gt;Our evaluation criteria were clear from the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turning data into UI&lt;/strong&gt; — how well a candidate presents a complex data structure as a usable interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handling the unhappy path&lt;/strong&gt; — consistent UX under network delays and API failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code written for collaborators&lt;/strong&gt; — clear naming, sensible comments, consistent patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sustainable architecture&lt;/strong&gt; — a structure that bends with changing requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plain CRUD app can't really surface most of those. So we set a different goal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let candidates experience the problems they'd actually face on our team.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That cuts both ways. Candidates get to see &lt;em&gt;how we solve problems here&lt;/em&gt;. We get to see &lt;em&gt;how they'd solve problems once they joined&lt;/em&gt;. It shows attitude and structural thinking better than raw implementation speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing a real-work simulation
&lt;/h2&gt;

&lt;p&gt;We took the UI, API, and logic from one of our production services and rebuilt a simplified version of it. Not a mock-up — a compressed set of situations frontend engineers hit every week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lists and conditional rendering&lt;/li&gt;
&lt;li&gt;Data caching and transformation&lt;/li&gt;
&lt;li&gt;Slow and failing API responses&lt;/li&gt;
&lt;li&gt;Responsive UI and state management&lt;/li&gt;
&lt;li&gt;Multi-step forms with validation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hidden traps in the requirements 🧩
&lt;/h2&gt;

&lt;p&gt;A task that only asks for features is boring and teaches nothing. So we deliberately planted situations you'd meet in real development:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Planted situation&lt;/th&gt;
&lt;th&gt;What it reveals&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Random API errors&lt;/td&gt;
&lt;td&gt;Error handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response delays&lt;/td&gt;
&lt;td&gt;Loading states and UX design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incomplete data&lt;/td&gt;
&lt;td&gt;Data shaping and caching strategy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependent API calls&lt;/td&gt;
&lt;td&gt;Async flow control and structure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These aren't gotchas. They're signals for how flexibly someone handles real-world mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we auto-grade with E2E
&lt;/h2&gt;

&lt;p&gt;We used to install, run, and check every submission by hand. It was slow, and every reviewer graded a little differently.&lt;/p&gt;

&lt;p&gt;Goals for automation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A consistent grading baseline&lt;/li&gt;
&lt;li&gt;Fast pass/fail on minimum requirements&lt;/li&gt;
&lt;li&gt;Less repetitive checking → more time for actual code review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We split grading into two tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core requirements&lt;/td&gt;
&lt;td&gt;Required features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-ended&lt;/td&gt;
&lt;td&gt;UX improvements, error handling, optimization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Less experienced candidates can still finish the core tier. Experienced candidates can show what they've got in the open-ended tier. The E2E suite grades the two tiers separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mock API instead of a local server
&lt;/h2&gt;

&lt;p&gt;We considered shipping a real server candidates would run locally, but chose a mock API instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No setup tax.&lt;/strong&gt; No env config, database, or auth just to start building UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A simple, stable response model.&lt;/strong&gt; We define exactly the fields the frontend needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small project.&lt;/strong&gt; No server to maintain means a faster start and less upkeep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Build fast, verify fast" — a mock API fit that best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making failure reproducible with MSW
&lt;/h2&gt;

&lt;p&gt;Real services always have a success path and a failure path. Against a real API, failure cases are surprisingly hard to reproduce on demand.&lt;/p&gt;

&lt;p&gt;So we built the &lt;strong&gt;MSW (Mock Service Worker)&lt;/strong&gt; handlers to read environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ERROR_RATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;span class="nv"&gt;DELAY_TIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, a share of requests fail on purpose and every response gets artificial latency. In E2E runs and QA we can test realistic scenarios like "10% failure rate" and "300 ms network delay," and check exactly how the UI reacts when things go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grading pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate PR → Docker build → E2E run → JSON report → Grade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F890fjkgfpqut0kretr3g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F890fjkgfpqut0kretr3g.png" alt="Auto-grading pipeline" width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The candidate opens a PR against the grading branch.&lt;/li&gt;
&lt;li&gt;CI builds and boots the app in Docker.&lt;/li&gt;
&lt;li&gt;Playwright runs the suite at &lt;strong&gt;desktop and mobile viewports in parallel&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Results are saved as a JSON report.&lt;/li&gt;
&lt;li&gt;The report is parsed into a grading summary and stored as a CI artifact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reviewers just open the branch to see the grade — no local setup required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we gained&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Noticeably faster candidate screening&lt;/li&gt;
&lt;li&gt;Interviews refocused on code review and technical conversation&lt;/li&gt;
&lt;li&gt;Candidate feedback along the lines of "it felt like I was briefly part of the team"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's next&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browser performance logging&lt;/li&gt;
&lt;li&gt;Automated screenshot comparison in E2E&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We still value the process of solving a problem over a perfect answer. A hiring assignment shouldn't just be an evaluation; it should be time for both sides to get to know each other.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Henry, a frontend engineer focused on React, Next.js, TypeScript and Playwright E2E testing. This post is adapted from a write-up I originally published in Korean. I'm open to frontend roles abroad — let's connect on &lt;a href="https://www.linkedin.com/in/jobkaehenry" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or see my work on &lt;a href="https://github.com/jobkaeHenry" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>msw</category>
      <category>hiring</category>
      <category>testing</category>
    </item>
    <item>
      <title>From an Express SPA to Next.js 16: A Test-First Strangler Fig Migration of a Shared Auth System</title>
      <dc:creator>Henry Lee</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:15:34 +0000</pubDate>
      <link>https://dev.to/jobkaehenry/from-an-express-spa-to-nextjs-16-a-test-first-strangler-fig-migration-of-a-shared-auth-system-1jfa</link>
      <guid>https://dev.to/jobkaehenry/from-an-express-spa-to-nextjs-16-a-test-first-strangler-fig-migration-of-a-shared-auth-system-1jfa</guid>
      <description>&lt;p&gt;I worked on a frontend team that owned the &lt;strong&gt;unified account system&lt;/strong&gt; for a group of creative-industry products. One login, several services — so if auth breaks, everything breaks.&lt;/p&gt;

&lt;p&gt;This post walks through how we moved that system from an Express-served React SPA to &lt;strong&gt;Next.js 16 (App Router)&lt;/strong&gt; without a big-bang rewrite, and why the very first thing we wrote wasn't application code, but E2E tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;The account system was a React SPA served by an Express server. It had run reliably for years, but the Express server had slowly absorbed too many responsibilities, all hard-coded in one place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API routing&lt;/li&gt;
&lt;li&gt;Session management&lt;/li&gt;
&lt;li&gt;Injecting meta tags for SEO&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1ct9snamep6degrbgdt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1ct9snamep6degrbgdt.png" alt="V1: Express does everything" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two problems followed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Poor separation of concerns.&lt;/strong&gt; One server doing everything meant every change touched the same tangled code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rising maintenance cost.&lt;/strong&gt; With every new feature it was unclear where code should live. Components were mixed together, and business logic was tightly coupled to UI.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Big bang vs. strangler fig
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option 1 — Full rewrite (big bang).&lt;/strong&gt; Start over and switch everything at once. The problem: several products depended on this login. A one-shot cut-over meant breaking compatibility everywhere at the same moment. The outage risk was too high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2 — Incremental migration (Strangler Fig pattern).&lt;/strong&gt; Keep the old system running and move traffic over piece by piece.&lt;/p&gt;

&lt;p&gt;Staying compatible while switching safely mattered more than anything else, so we chose the strangler fig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step zero: E2E tests before any code
&lt;/h2&gt;

&lt;p&gt;After choosing an incremental migration, the first thing we did was &lt;strong&gt;reach 100% E2E coverage of V1's key user flows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The scariest thing when you build a replacement is "something that used to work doesn't anymore." Login, sign-up, password reset, social login, linking legacy accounts — if any one of those breaks, users feel it immediately.&lt;/p&gt;

&lt;p&gt;So we documented every critical V1 flow as a &lt;strong&gt;Playwright&lt;/strong&gt; test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// e2e/auth.spec.ts&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redirects to My Page after a successful login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v2/auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="email"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="password"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v2/mypage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.user-email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locks the account after 5 failed attempts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="password"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wrong&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.error-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sends legacy accounts to the account-linking page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// resultCode 203 case&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="email"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;legacy@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;v2&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;auth&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;connect/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests became &lt;strong&gt;the spec for V2&lt;/strong&gt;. When V2 code passed them, we could say with confidence: "it behaves exactly like V1."&lt;/p&gt;

&lt;h2&gt;
  
  
  TDD on top of the E2E suite
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63cr7vg3ucmw3ba0bwhs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63cr7vg3ucmw3ba0bwhs.png" alt="TDD loop on top of the E2E suite" width="800" height="167"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Red&lt;/strong&gt; — run the E2E suite against V2. It fails, because there is no V2 code yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green&lt;/strong&gt; — write just enough code to pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactor&lt;/strong&gt; — clean up while the tests stay green.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This guaranteed "whatever worked in V1 works in V2" at the level of &lt;strong&gt;user scenarios&lt;/strong&gt;, not just at the level of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Next.js 16 + App Router
&lt;/h2&gt;

&lt;p&gt;The biggest pain in the old Express server was that it had &lt;strong&gt;no TypeScript&lt;/strong&gt;. Maintaining it without type safety kept getting harder.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NestJS&lt;/td&gt;
&lt;td&gt;TypeScript, structured backend&lt;/td&gt;
&lt;td&gt;Backend only; frontend still managed separately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go + Echo&lt;/td&gt;
&lt;td&gt;Fast, type-safe&lt;/td&gt;
&lt;td&gt;Hard for a frontend team to own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js App Router&lt;/td&gt;
&lt;td&gt;TypeScript by default, full-stack&lt;/td&gt;
&lt;td&gt;Tied to the React ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We seriously considered writing a new backend in NestJS or Go. What decided it was ownership: &lt;strong&gt;the frontend team was going to maintain this system.&lt;/strong&gt; Next.js let us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write UI and API in the same language (TypeScript)&lt;/li&gt;
&lt;li&gt;Keep everything in one repo&lt;/li&gt;
&lt;li&gt;Use one deployment pipeline&lt;/li&gt;
&lt;li&gt;Share types between React components and API logic&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hardest part: session compatibility
&lt;/h2&gt;

&lt;p&gt;V1 used &lt;code&gt;express-session&lt;/code&gt; backed by Redis. The new Next.js app had to &lt;strong&gt;read and write the exact same sessions&lt;/strong&gt;, so a user logged in on a V1 page stays logged in on a V2 page, and vice versa.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;express-session&lt;/code&gt; uses a signed cookie:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cookie name: &lt;code&gt;connect.sid&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Cookie value: &lt;code&gt;s:{sessionId}.{HMAC_signature}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the session lives in Redis like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"originalMaxAge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;604800000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"secure"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"httpOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sameSite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJhbG..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJhbG..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Next.js side we implemented the same contract: parse &lt;code&gt;connect.sid&lt;/code&gt;, verify the signature with the same secret, and read/write the same Redis key. With that in place, &lt;strong&gt;V1 and V2 share one session&lt;/strong&gt;, and users get the same experience no matter which version serves the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  API strategy: switch over gradually
&lt;/h2&gt;

&lt;p&gt;For now, V2 pages still call V1's API through a Next.js rewrite proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;beforeFiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/front/api/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EXPRESS_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/front/api/:path*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9lp8kjvrzbdj83nlvszj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9lp8kjvrzbdj83nlvszj.png" alt="Migration phases" width="800" height="169"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;V1's Express API talks to Keycloak, and V2's API Routes are already implemented to talk to Keycloak the same way. In a later phase, Next.js talks to Keycloak directly and Express drops out of the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we kept Nginx in front
&lt;/h2&gt;

&lt;p&gt;In V1, Nginx served static files and Express handled the API. In V2, Next.js handles both pages and API. So why keep Nginx?&lt;/p&gt;

&lt;p&gt;Because it still does infrastructure-level work that doesn't belong in application code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security headers&lt;/td&gt;
&lt;td&gt;HSTS, X-Frame-Options, CSP, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAF&lt;/td&gt;
&lt;td&gt;Web application firewall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limiting&lt;/td&gt;
&lt;td&gt;Request limits, DDoS mitigation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=31536000"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=api&lt;/span&gt; &lt;span class="s"&gt;burst=20&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://nextjs:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe6p861b465yr8198c2al.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe6p861b465yr8198c2al.png" alt="V2: Next.js behind Nginx, sharing the Redis session with V1" width="799" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nginx keeps its mature, battle-tested security features; the application logic lives in Next.js and TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stability backed by E2E tests.&lt;/strong&gt; We hit 100% E2E coverage of V1's key flows first and built V2 against it, so the migration shipped &lt;strong&gt;without regressions&lt;/strong&gt; in those flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full compatibility.&lt;/strong&gt; V1 and V2 share the same session. Users see no difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type safety.&lt;/strong&gt; With Zod + TypeScript, runtime errors dropped significantly. API responses and form data are typed end to end.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Phase 1 is complete. Next: turn on all V2 API Routes and retire the legacy code step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The one thing we optimized for was &lt;strong&gt;stability&lt;/strong&gt;, so we wrote the tests before the code. We documented every critical V1 flow as an E2E test and built V2 by making those tests pass.&lt;/p&gt;

&lt;p&gt;"If the tests pass, it behaves like V1." That confidence is what made an incremental migration of a system everyone depended on actually feel safe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Henry, a frontend engineer focused on React, Next.js, TypeScript and Playwright E2E testing. This post is adapted from a write-up I originally published in Korean. I'm open to frontend roles abroad — let's connect on &lt;a href="https://www.linkedin.com/in/jobkaehenry" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or see my work on &lt;a href="https://github.com/jobkaeHenry" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>playwright</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
