<?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>Forem: Subito</title>
    <description>The latest articles on Forem by Subito (@subito).</description>
    <link>https://forem.com/subito</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.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F11541%2F15e987ad-aa1f-49ed-8bf9-114aabf60069.png</url>
      <title>Forem: Subito</title>
      <link>https://forem.com/subito</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/subito"/>
    <language>en</language>
    <item>
      <title>Improving Data Fetching in Next.js: Lessons from Moving Beyond useEffect</title>
      <dc:creator>Chao Fang</dc:creator>
      <pubDate>Mon, 16 Mar 2026 16:19:02 +0000</pubDate>
      <link>https://forem.com/subito/improving-data-fetching-in-nextjs-lessons-from-moving-beyond-useeffect-4a2i</link>
      <guid>https://forem.com/subito/improving-data-fetching-in-nextjs-lessons-from-moving-beyond-useeffect-4a2i</guid>
      <description>&lt;p&gt;At &lt;strong&gt;Subito&lt;/strong&gt; (Italy's leading classifieds platform), we never paid much attention to how we made API calls in our &lt;a href="https://dev.to/subito/from-independent-microsites-to-context-driven-architecture-5166"&gt;web frontend microsites&lt;/a&gt;, all built on &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;. We had a simple library based on &lt;a href="https://axios-http.com/" rel="noopener noreferrer"&gt;Axios&lt;/a&gt; that handled everything: making requests, modeling data, and managing errors.&lt;/p&gt;

&lt;p&gt;On the server side, it was a simple call; on the client side, we used &lt;code&gt;useEffect&lt;/code&gt; with state management for data and errors. It worked, until we started asking: &lt;strong&gt;is this still the right approach?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wake-Up Call: The Cloudflare Incident
&lt;/h2&gt;

&lt;p&gt;The turning point was reading about the &lt;a href="https://blog.cloudflare.com/deep-dive-into-cloudflares-sept-12-dashboard-and-api-outage/" rel="noopener noreferrer"&gt;Cloudflare outage&lt;/a&gt; caused by excessive API calls triggered by &lt;code&gt;useEffect&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It made us reflect on our own stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are we over-fetching?&lt;/li&gt;
&lt;li&gt;Are we accidentally triggering duplicate requests?&lt;/li&gt;
&lt;li&gt;Could we overload our backend without realizing it?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Mission
&lt;/h2&gt;

&lt;p&gt;Our primary goal was simple: &lt;strong&gt;embrace the standard&lt;/strong&gt;! We wanted to align perfectly with the official recommendations from React and Next.js.&lt;/p&gt;

&lt;p&gt;Since our architecture relies on the Next.js App Router to consume REST APIs, we needed a clean, well-defined data-fetching strategy that clearly distinguishes between React Server Components and Client Components.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Server Side: embracing the standard
&lt;/h2&gt;

&lt;p&gt;For Server Components, the decision was surprisingly easy: Next.js extends the native &lt;code&gt;fetch&lt;/code&gt; API, adding powerful caching and revalidation features out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did:&lt;/strong&gt; we replaced all Axios calls in our Server Components with the native &lt;a href="https://nextjs.org/docs/app/api-reference/functions/fetch" rel="noopener noreferrer"&gt;fetch API extended by Next.js&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This "simple" switch allowed us to remove Axios entirely from our code, a library that, while historic, adds ~13kb to the bundle and has had &lt;strong&gt;multiple security advisories over the years&lt;/strong&gt; (&lt;em&gt;Axios served us well for years and played a huge role in making HTTP requests easier across the JavaScript ecosystem; but it was time to move on&lt;/em&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Client Side: the great debate (React Query vs. SWR)
&lt;/h2&gt;

&lt;p&gt;While the server side was easy, the client side required a comparison between two libraries: &lt;a href="https://tanstack.com/query/latest" rel="noopener noreferrer"&gt;TanStack Query (React Query)&lt;/a&gt; and &lt;a href="https://swr.vercel.app/" rel="noopener noreferrer"&gt;SWR&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The React Query Experiment
&lt;/h3&gt;

&lt;p&gt;We started with &lt;a href="https://tanstack.com/query/latest" rel="noopener noreferrer"&gt;React Query&lt;/a&gt;: it has gained massive popularity in the React ecosystem, and for good reason!&lt;br&gt;
It's incredibly powerful, a true "server state" manager; we had to try it!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we found:&lt;/strong&gt; for our use case it felt like overkill: it requires a &lt;code&gt;QueryClient&lt;/code&gt;, a &lt;code&gt;Provider&lt;/code&gt;, and careful &lt;code&gt;queryKey&lt;/code&gt; management.&lt;br&gt;
The learning curve was steeper than expected, and integrating it into our existing microsites wasn't trivial. The mental shift and boilerplate needed slowed down adoption.&lt;br&gt;
On top of that, we don't really need mutations or cache invalidation on the client since our caching is handled server-side by our backend REST APIs.&lt;/p&gt;
&lt;h3&gt;
  
  
  The SWR "Toy" that Could
&lt;/h3&gt;

&lt;p&gt;Then we tried &lt;strong&gt;&lt;a href="https://swr.vercel.app/" rel="noopener noreferrer"&gt;SWR&lt;/a&gt;&lt;/strong&gt; (Stale-While-Revalidate) by Vercel.&lt;br&gt;
One of our colleagues jokingly called it a "giocattolino" ("a little toy" in italian) because of how simple it is. But for our use case, it was perfect!&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Boilerplate:&lt;/strong&gt; No mandatory providers. Just a hook: &lt;a href="https://swr.vercel.app/docs/getting-started" rel="noopener noreferrer"&gt;&lt;code&gt;useSWR(key, fetcher)&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Learning Curve:&lt;/strong&gt; It felt like a natural extension of the platform&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-only by design:&lt;/strong&gt; SWR is focused solely on Client Components, exactly what we needed, since we already solved server-side fetching with native fetch&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;p&gt;The move from &lt;code&gt;useEffect&lt;/code&gt; to &lt;code&gt;SWR&lt;/code&gt; felt like cleaning a cluttered room. Here's a real example from our codebase:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (useEffect):&lt;/strong&gt;&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AdItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// load the Ads&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&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="nf"&gt;getRecommendedItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="nx"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (SWR):&lt;/strong&gt;&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSWR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;SWR_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recommender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;fetchRecommendedItems&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We deleted two &lt;code&gt;useState&lt;/code&gt; declarations and replaced a bulky &lt;code&gt;useEffect&lt;/code&gt; block with a single, declarative hook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Centralized Key Management
&lt;/h3&gt;

&lt;p&gt;To avoid "magic strings" and ensure consistent caching, we centralized our SWR keys:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SWR_KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;recommender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recommender&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;items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;Simple, type-safe, and predictable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing Without the Headache
&lt;/h3&gt;

&lt;p&gt;Since SWR caches globally, we developed a utility to ensure our unit tests stay isolated:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SWRConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;renderWithSWR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactElement&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;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SWRConfig&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;dedupingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;()&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="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/SWRConfig&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Usage: renderWithSWR(&amp;lt;RecommenderWidget /&amp;gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;By moving away from manual &lt;code&gt;useEffect&lt;/code&gt; fetching and adopting the &lt;strong&gt;SWR + Native Fetch&lt;/strong&gt; combo, we've achieved cleaner code and a much more React-standard way to handle data fetching.&lt;/p&gt;

&lt;p&gt;Sometimes the simplest tool is the most effective one. Our use case was clear: few mutations on the client side, no shared cache needed between server and client, and no client-side caching required since we're a pure frontend team calling REST services that handle their own caching.&lt;/p&gt;

&lt;p&gt;For this scenario, SWR turned out to be the most immediate, simple, and fitting solution. No over-engineering, just the right tool for the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The big takeaway:&lt;/strong&gt; don't just follow the hype. React Query is an amazing library, if you need complex cache invalidation, optimistic updates, or tight server-client state synchronization, it's probably the right choice. For us, it wasn't. &lt;strong&gt;Understand your actual requirements first, then pick the tool that fits, not the other way around&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>swr</category>
      <category>react</category>
    </item>
    <item>
      <title>How We Reduced INP by 100ms+: GTM Isolation, React Compiler, and Better Telemetry</title>
      <dc:creator>Francesca Milan</dc:creator>
      <pubDate>Tue, 17 Feb 2026 16:19:34 +0000</pubDate>
      <link>https://forem.com/subito/how-we-reduced-inp-by-100ms-gtm-isolation-react-compiler-and-better-telemetry-315g</link>
      <guid>https://forem.com/subito/how-we-reduced-inp-by-100ms-gtm-isolation-react-compiler-and-better-telemetry-315g</guid>
      <description>&lt;p&gt;At &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;Subito&lt;/a&gt; (Italy's leading classifieds platforms), we constantly monitor our Core Web Vitals. While we had a handle on LCP and CLS, we were constantly struggling with &lt;strong&gt;&lt;a href="https://web.dev/articles/inp" rel="noopener noreferrer"&gt;INP (Interaction to Next Paint)&lt;/a&gt;&lt;/strong&gt; on our high-traffic public pages, specifically our &lt;strong&gt;Listing&lt;/strong&gt; and &lt;strong&gt;Ad Details&lt;/strong&gt; pages.&lt;/p&gt;

&lt;p&gt;Unlike LCP or CLS, where we have automated alerts triggering when thresholds are breached, setting up alerts for INP seemed impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is INP?
&lt;/h2&gt;

&lt;p&gt;For context, &lt;strong&gt;INP&lt;/strong&gt; measures a page's responsiveness. It observes the latency of all user interactions (clicks, taps, and key presses) throughout the lifespan of a user's visit. The final value is the longest interaction observed (ignoring outliers).&lt;/p&gt;

&lt;p&gt;According to Google's standards, a "Good" experience is defined by strictly defined thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;Good:&lt;/strong&gt; &lt;strong&gt;200ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🟡 &lt;strong&gt;Needs Improvement:&lt;/strong&gt; Between &lt;strong&gt;200ms&lt;/strong&gt; and &lt;strong&gt;500ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🔴 &lt;strong&gt;Poor:&lt;/strong&gt; &lt;strong&gt;500ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;INP is about how "fast" the site feels when you try to use it. A poor INP means the user clicks a button and... waits. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: "It's Not Just Us"
&lt;/h2&gt;

&lt;p&gt;We struggled with INP because &lt;strong&gt;fluctuations weren't always caused by our code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On our highest-traffic pages, two major actors are almost entirely out of our direct control:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;a href="https://marketingplatform.google.com/about/tag-manager/" rel="noopener noreferrer"&gt;GTM (Google Tag Manager)&lt;/a&gt;:&lt;/strong&gt; Managed by our Marketing team.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;ADV (Advertising):&lt;/strong&gt; Managed by our Sales/Advertising team.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both inject code and event listeners that heavily impact performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old "Empiric" Way (And Why It Failed)
&lt;/h2&gt;

&lt;p&gt;Previously, our monitoring was purely observational via Grafana. When INP spiked, we would scramble to check recent releases. Most of the time, our code changes weren't the culprit, and rollbacks did nothing.&lt;/p&gt;

&lt;p&gt;We resorted to an empiric, frustrating debugging process: open Chrome DevTools, throttle the CPU to 4x, simulate a 4G network, and click around hoping to reproduce the lag. It wasn't &lt;em&gt;wrong&lt;/em&gt;, but it was inefficient and lacked engineering rigor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Scientific" Approach: Isolating the Actors
&lt;/h2&gt;

&lt;p&gt;This year, the Frontend Chapter decided to stop guessing. We needed to measure the specific weight of the three actors: &lt;strong&gt;Our Code, GTM, and ADV.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With approval from the respective teams, we ran an A/B test on &lt;strong&gt;1% of our traffic&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Segment A:&lt;/strong&gt; Loaded without GTM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment B:&lt;/strong&gt; Loaded without ADV.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment C:&lt;/strong&gt; Standard traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This finally gave us clear baselines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study 1: The Ad Details Page
&lt;/h3&gt;

&lt;p&gt;Here is what we found for the Ad Details page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard INP:&lt;/strong&gt; 208ms (Needs Improvement)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP without ADV:&lt;/strong&gt; 180ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP without GTM:&lt;/strong&gt; 112ms (Good!)&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.amazonaws.com%2Fuploads%2Farticles%2Fxdkravmxc30iiogh7kua.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.amazonaws.com%2Fuploads%2Farticles%2Fxdkravmxc30iiogh7kua.png" alt="INP Data Ad Details" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fnhoi9g5y3kq6gce3fdgu.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.amazonaws.com%2Fuploads%2Farticles%2Fnhoi9g5y3kq6gce3fdgu.png" alt="INP Graph Ad Details" width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The data was undeniable: &lt;strong&gt;GTM was the primary bottleneck.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study 2: The Listing Page
&lt;/h3&gt;

&lt;p&gt;The Listing page was more complex. Even without external scripts, our baseline was high:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard INP:&lt;/strong&gt; 345ms (Very Poor)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP without ADV:&lt;/strong&gt; 279ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP without GTM:&lt;/strong&gt; 320ms&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.amazonaws.com%2Fuploads%2Farticles%2Fsiaoyuk6ozv3w180opn0.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.amazonaws.com%2Fuploads%2Farticles%2Fsiaoyuk6ozv3w180opn0.png" alt="INP Data Listing" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here, removing GTM didn't help much, and removing ADV helped but didn't solve it. We had to dig deeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solving the GTM Issue (Ad Details)
&lt;/h2&gt;

&lt;p&gt;Once we knew GTM was the culprit on the Ad Details page, we collaborated closely with the Marketing team.&lt;/p&gt;

&lt;p&gt;GTM works via triggers (events) that inject JavaScript (tags). To find the needle in the haystack, we cloned the GTM workspace for our 1% traffic slice and used a &lt;strong&gt;"Bisect" approach&lt;/strong&gt; (binary search):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; We disabled 50% of the triggers.&lt;/li&gt;
&lt;li&gt; Monitored the INP.&lt;/li&gt;
&lt;li&gt; If improved, the issue was in that 50%. If not, we checked the other half.&lt;/li&gt;
&lt;li&gt; Repeat until the specific script is found.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; The heavy hitters were tracking scripts for &lt;strong&gt;TikTok and Facebook&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of complex engineering workarounds to move these scripts out of GTM, the Marketing team simply agreed to disable the TikTok tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; INP dropped from &lt;strong&gt;208ms to ~170ms&lt;/strong&gt;. We were finally under the 200ms threshold! 🎉&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Frqps98r7fawjfi8xf97u.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.amazonaws.com%2Fuploads%2Farticles%2Frqps98r7fawjfi8xf97u.png" alt="INP Drop Ad Details" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Solving the Complex Issue (Listing Page)
&lt;/h2&gt;

&lt;p&gt;The Listing page was harder because there was no single "culprit."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Better Telemetry with Grafana Faro
&lt;/h3&gt;

&lt;p&gt;We integrated Google's &lt;a href="https://github.com/GoogleChrome/web-vitals" rel="noopener noreferrer"&gt;web-vitals&lt;/a&gt; library to capture attribution data (&lt;a href="https://github.com/GoogleChrome/web-vitals/blob/main/src/types/inp.ts#L141" rel="noopener noreferrer"&gt;longestScriptURL&lt;/a&gt;, &lt;a href="https://github.com/GoogleChrome/web-vitals/blob/main/src/types/inp.ts#L71" rel="noopener noreferrer"&gt;interactionTarget&lt;/a&gt;) and sent it to &lt;a href="https://grafana.com/oss/faro/" rel="noopener noreferrer"&gt;Grafana Faro&lt;/a&gt;:&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="nf"&gt;getFaro&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushMeasurement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;values&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;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;beacon&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;This allowed us to visualize exactly &lt;em&gt;which&lt;/em&gt; scripts and &lt;em&gt;which&lt;/em&gt; DOM elements were causing delays.&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2F74ku11cbispnolbxkoyp.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.amazonaws.com%2Fuploads%2Farticles%2F74ku11cbispnolbxkoyp.png" alt="Grafana Faro Logs" width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We discovered that clicks on &lt;code&gt;a.index-module_link&lt;/code&gt; were problematic. These links had heavy event handlers attached by &lt;strong&gt;interstitial ads&lt;/strong&gt; (full-page ads).&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Ftwqlxwadlwenmeofyjzy.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.amazonaws.com%2Fuploads%2Farticles%2Ftwqlxwadlwenmeofyjzy.png" alt="DOM Element Analysis" width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We are still negotiating a fix with the ADV team, but we couldn't just wait.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Enter the React Compiler
&lt;/h3&gt;

&lt;p&gt;To optimize the code we &lt;em&gt;did&lt;/em&gt; control, we decided to upgrade to &lt;strong&gt;Next.js 16&lt;/strong&gt; and enable the &lt;strong&gt;React Compiler&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The upgrade process wasn't difficult (though switching from Webpack to Turbopack was an adventure for another article).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Result:&lt;/strong&gt;&lt;br&gt;
Surprisingly, just enabling the React Compiler significantly dropped our INP:&lt;br&gt;
&lt;strong&gt;From 345ms down to 271ms.&lt;/strong&gt;&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Faty1qys3msz3ybj05p6s.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.amazonaws.com%2Fuploads%2Farticles%2Faty1qys3msz3ybj05p6s.png" alt="React Compiler Result" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's still not perfect, but it was a massive free performance win. If we calculate the "No ADV" scenario combined with React Compiler, we would actually be under the threshold:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fpj2a3s86ph8bhwpspw2h.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.amazonaws.com%2Fuploads%2Farticles%2Fpj2a3s86ph8bhwpspw2h.png" alt="React Compiler No ADV" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We aren't done yet, but this journey taught us valuable lessons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Don't Guess, Measure:&lt;/strong&gt; Isolate your third parties (GTM, ADV) using traffic splitting (even 1-2%). It gives you leverage and data to drive decisions.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;React Compiler is Legit:&lt;/strong&gt; It's not hard to introduce, and it can provide a significant performance boost for free.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Collaborate, Don't Hack:&lt;/strong&gt; We avoided clever technical workarounds (like the &lt;a href="https://www.corewebvitals.io/pagespeed/datalayer-inp-yield-pattern" rel="noopener noreferrer"&gt;DataLayer yield pattern&lt;/a&gt;). Why? Because talking to the Marketing team and solving the root cause is sustainable. Hacks are not.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Next Steps
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Enable automated alerting for INP now that we understand the baseline.&lt;/li&gt;
&lt;li&gt;Continue optimizing the Listing page to reach the green threshold (sub 200ms).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Big Goal:&lt;/strong&gt; Treat Core Web Vitals as business metrics. We want to establish "Performance Budgets" (e.g., GTM gets max 50ms, ADV gets max 50ms) to ensure quality isn't sacrificed for tracking.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>react</category>
      <category>nextjs</category>
      <category>webperf</category>
    </item>
    <item>
      <title>How We Automate Accessibility Testing with Playwright and Axe</title>
      <dc:creator>Bojan</dc:creator>
      <pubDate>Wed, 10 Dec 2025 13:18:44 +0000</pubDate>
      <link>https://forem.com/subito/how-we-automate-accessibility-testing-with-playwright-and-axe-3ok5</link>
      <guid>https://forem.com/subito/how-we-automate-accessibility-testing-with-playwright-and-axe-3ok5</guid>
      <description>&lt;p&gt;At Subito, accessibility (a11y) is an important requirement for ensuring all users, regardless of their abilities or disabilities, can use our platform effectively. An accessible website improves the user experience for everyone, including those using assistive technologies like screen readers or alternative input devices.&lt;/p&gt;

&lt;p&gt;In this article, we'll show you how we use &lt;strong&gt;Playwright&lt;/strong&gt; combined with &lt;strong&gt;Axe&lt;/strong&gt; (&lt;code&gt;@axe-core/playwright&lt;/code&gt;) to automatically catch accessibility issues and integrate them into our CI pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Toolkit: Axe + Playwright
&lt;/h2&gt;

&lt;p&gt;We chose &lt;strong&gt;Axe&lt;/strong&gt;, an open-source library from &lt;a href="https://github.com/dequelabs/axe-core" rel="noopener noreferrer"&gt;Deque Systems&lt;/a&gt;, as our accessibility testing engine. It's well-regarded, easy to use, and provides a JavaScript API to run tests directly in the browser. The &lt;a href="https://github.com/dequelabs/axe-core-npm" rel="noopener noreferrer"&gt;@axe-core/playwright&lt;/a&gt; package makes integration seamless.&lt;/p&gt;

&lt;p&gt;And since we already rely on Playwright for &lt;a href="https://dev.to/subito/how-we-catch-ui-bugs-early-with-visual-regression-testing-and-playwright-5h2a"&gt;visual regression testing&lt;/a&gt; and our end-to-end suite, adding accessibility checks right on top of that felt like the obvious next step. &lt;br&gt;
No new tools to learn, just extending a setup we know well with Axe’s engine running inside the same Playwright workflows.&lt;/p&gt;
&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;First, we created a helper to get a pre-configured Axe instance. Our configuration focuses on &lt;strong&gt;WCAG 2.1 Level A and AA&lt;/strong&gt; criteria.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is WCAG?&lt;/strong&gt; The Web Content Accessibility Guidelines (&lt;a href="https://www.w3.org/WAI/standards-guidelines/wcag" rel="noopener noreferrer"&gt;WCAG&lt;/a&gt; are developed by the W3C to make web content more accessible.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Level A:&lt;/strong&gt; The minimum level of conformance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level AA:&lt;/strong&gt; The mid-range level we (and many others) target, as it addresses more advanced barriers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level AAA:&lt;/strong&gt; The highest, most stringent level.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;We also explicitly &lt;code&gt;exclude&lt;/code&gt; certain elements that are outside our direct control, such as third-party advertisements positions, to avoid false positives.&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;// From /test/utils/axe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AxeBuilder&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@axe-core/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getAxeInstance&lt;/span&gt; &lt;span class="o"&gt;=&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="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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AxeBuilder&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="c1"&gt;// We decided to target WCAG 2.1 A and AA success criteria&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withTags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2a&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;wcag2aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="c1"&gt;// We exclude elements we don't control, like ads&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[id^="google_ads_iframe_"]&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;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#skinadvtop2&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;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#subito_skin_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Implementation: Generating and Saving Reports
&lt;/h3&gt;

&lt;p&gt;Next, we implemented another helper function, &lt;code&gt;generateAxeReport&lt;/code&gt;, to run the analysis and save the results.&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;// From /test/utils/axe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AxeBuilder&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@axe-core/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axe-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... getAxeInstance code from above ...&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generateAxeReport&lt;/span&gt; &lt;span class="o"&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&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="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;includeSelector&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;string&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;axe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getAxeInstance&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="c1"&gt;// Optionally scope the analysis to a specific selector&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;includeSelector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;axe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;axe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;includeSelector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Save the results to a JSON file&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAccessibilityResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveAccessibilityResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outputDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test/a11y/output&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// Create directory if it doesn't exist&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;outputDir&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;fileName&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;isMobile&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mobile&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;desktop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// We map the violations to a clean object for serialization&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escapedViolations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;violation&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;impact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;help&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;help&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;helpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;helpUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// The specific elements that failed&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;escapedViolations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Accessibility results saved to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The A11y test
&lt;/h3&gt;

&lt;p&gt;With these helpers in place, adding an accessibility check to any Playwright test is incredibly simple.&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;// From /test/a11y/example.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateAxeReport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../utils/axe&lt;/span&gt;&lt;span class="dl"&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;check Login 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="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;/login_form&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;waitForLoadState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Just call our helper!&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateAxeReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login-page&lt;/span&gt;&lt;span class="dl"&gt;'&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="kc"&gt;false&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;It generates a JSON report for the login-page, essentially, each test run produces a structured JSON output with all the accessibility findings:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2F5n9zxfm26hd7r5khgd20.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.amazonaws.com%2Fuploads%2Farticles%2F5n9zxfm26hd7r5khgd20.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration with Continuous Integration (CI)
&lt;/h2&gt;

&lt;p&gt;Our workflow is triggered every time our staging environment is updated. The action performs the following steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Runs&lt;/strong&gt; the accessibility tests against a predefined list of critical pages.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Generates&lt;/strong&gt; the JSON reports.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Updates or creates&lt;/strong&gt; a dedicated GitHub Issue with the results whenever violations are detected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what the automated report looks like when posted to our GitHub Issue:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fl956kqrydvhk8m29a3o0.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.amazonaws.com%2Fuploads%2Farticles%2Fl956kqrydvhk8m29a3o0.png" alt="Example of an automatically generated accessibility report in a GitHub issue" width="800" height="637"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here is the detail of the violations found:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2F9x253vgvvyt8htkq0bsy.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.amazonaws.com%2Fuploads%2Farticles%2F9x253vgvvyt8htkq0bsy.png" alt=" " width="800" height="897"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a GitHub Issue? (And Not a Failing Build)
&lt;/h3&gt;

&lt;p&gt;This is a key difference from our &lt;a href="https://dev.to/subito/how-we-catch-ui-bugs-early-with-visual-regression-testing-and-playwright-5h2a"&gt;visual regression tests&lt;/a&gt;, which automatically open a PR and send a Slack notification to the engineer who introduced the visual change. &lt;/p&gt;

&lt;p&gt;Since we’ve only recently introduced automated a11y checks, there’s naturally a lot of work to catch up on. We’re fixing issues progressively, but until the overall accessibility debt gets closer to zero, blocking or slowing down the pipeline wouldn’t be sustainable.&lt;/p&gt;

&lt;p&gt;At the same time, using a GitHub Issue, we create a persistent record of the accessibility debt; &lt;strong&gt;the repo owner is then responsible for triaging these issues, assessing their priority, and scheduling the fixes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Below is an example of a pull request where we address a record previously logged in the GitHub Issue:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fnvkghnn9rhhi2c454vc6.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.amazonaws.com%2Fuploads%2Farticles%2Fnvkghnn9rhhi2c454vc6.png" alt=" " width="800" height="528"&gt;&lt;/a&gt;&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2F1f3co77dh263muku8qb7.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.amazonaws.com%2Fuploads%2Farticles%2F1f3co77dh263muku8qb7.png" alt=" " width="800" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Automation &lt;em&gt;Really&lt;/em&gt; Finds
&lt;/h2&gt;

&lt;p&gt;We had high hopes for catching complex navigation issues, but the reality is that &lt;strong&gt;automated tests are best at finding basic problems&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What our tests &lt;em&gt;do&lt;/em&gt; catch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing alternative text for images (&lt;code&gt;alt&lt;/code&gt; attributes)&lt;/li&gt;
&lt;li&gt;Color contrast problems&lt;/li&gt;
&lt;li&gt;Semantic HTML errors (e.g., improper heading structure)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What our tests &lt;em&gt;don't&lt;/em&gt; catch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex keyboard navigability issues&lt;/li&gt;
&lt;li&gt;Clarity or comprehensibility of content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These more complex issues still require manual testing and review by accessibility experts (for now).&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;We plan to add &lt;strong&gt;Slack notifications&lt;/strong&gt; to our GitHub Action; this notification will fire &lt;em&gt;only when new violations are introduced&lt;/em&gt;. &lt;br&gt;
While the GitHub Issue tracks our overall a11y debt, a new problem introduced by a recent deployment to staging needs to be fixed &lt;strong&gt;immediately&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Automating accessibility testing with Playwright and Axe doesn’t find every a11y problem, but it gives us a baseline that runs on every PR and helps us catch the obvious issues before they ever reach production. &lt;/p&gt;

&lt;p&gt;There’s still plenty we want to explore, but this already feels like a solid step forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check Out Our Code
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Example Test:&lt;/strong&gt; &lt;a href="https://github.com/Subito-it/articles-code/blob/main/a11y-example/test/a11y/example.spec.ts" rel="noopener noreferrer"&gt;github.com/Subito-it/articles-code/blob/main/a11y-example/test/a11y/example.spec.ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Axe Utilities:&lt;/strong&gt; &lt;a href="https://github.com/Subito-it/articles-code/blob/main/a11y-example/test/utils/axe.ts" rel="noopener noreferrer"&gt;github.com/Subito-it/articles-code/blob/main/a11y-example/test/utils/axe.ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Action:&lt;/strong&gt; &lt;a href="https://github.com/Subito-it/articles-code/blob/main/.github/workflows/a11y.yml" rel="noopener noreferrer"&gt;https://github.com/Subito-it/articles-code/blob/main/.github/workflows/a11y.yml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>playwright</category>
      <category>ux</category>
    </item>
    <item>
      <title>How We Catch UI Bugs Early with Visual Regression Testing</title>
      <dc:creator>Tommaso Ruscica</dc:creator>
      <pubDate>Wed, 12 Nov 2025 14:33:30 +0000</pubDate>
      <link>https://forem.com/subito/how-we-catch-ui-bugs-early-with-visual-regression-testing-and-playwright-5h2a</link>
      <guid>https://forem.com/subito/how-we-catch-ui-bugs-early-with-visual-regression-testing-and-playwright-5h2a</guid>
      <description>&lt;p&gt;Visual regression testing is a powerful technique to ensure our web application looks as expected, even as code changes over time. In this article, we walk through what a visual regression testing tool is, why it’s important, and how we implemented it in our CI/CD pipeline using Playwright, GitHub Actions, and Git LFS at &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt;, &lt;strong&gt;Italy’s leading online classifieds platform.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Visual Regression Testing Tool?
&lt;/h2&gt;

&lt;p&gt;A visual regression testing tool automatically detects changes in the visual appearance of an application or website. It works by taking screenshots of pages or components and comparing them to previously approved "baseline" images. If any unexpected differences are found, the tool flags them for review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Do You Need It?
&lt;/h2&gt;

&lt;p&gt;CSS and layout changes can have unintended side effects, breaking parts of the UI in subtle ways. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A global CSS tweak makes buttons unreadable.&lt;/li&gt;
&lt;li&gt;A new font or asset isn’t loaded correctly.&lt;/li&gt;
&lt;li&gt;A component’s layout shifts, breaking alignment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visual regression testing helps us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Catch these issues early, before they reach production.&lt;/li&gt;
&lt;li&gt;Document intentional UI changes over time.&lt;/li&gt;
&lt;li&gt;Maintain a consistent user experience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While this tool seems theoretically useful for supporting delivery, let’s share some real examples from our experience to help you better understand the value of this additional tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 1: Login modal, Submit button not visible&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our "login in place" feature is designed not to show the vertical scrollbar. &lt;br&gt;
A recent change caused the submit button to be pushed off-screen, making it inaccessible to users (See the left image below).&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2F6obux496h2lq61fta686.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.amazonaws.com%2Fuploads%2Farticles%2F6obux496h2lq61fta686.png" alt=" " width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This login modal is crucial for us at &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt; because it allows users to log in without leaving the current page after an action that requires authentication, such as adding an ad to favorites. &lt;br&gt;
Thanks to our visual regression testing tool, we caught this error.&lt;/p&gt;

&lt;p&gt;Both unit tests (using Jest) and integration tests (via Playwright) were still passing because they were both able to click the button programmatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 2: Forgotten font import in the Home Page&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During a recent update to our Home Page, a font import was accidentally omitted from the CSS. &lt;br&gt;
Thanks to visual regression testing, we caught the issue before it went live. The screenshots below show the difference:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fp8yjsr2itl0k0z1tguwl.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.amazonaws.com%2Fuploads%2Farticles%2Fp8yjsr2itl0k0z1tguwl.png" alt=" " width="800" height="87"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can see how the text looks different due to the missing font, which would have negatively impacted user experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 3: Bug caused by CSS import reordering in Next.js&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recently, we introduced an ESLint rule to enforce grouping and alphabetical ordering of imports in our files. &lt;br&gt;
The related PR was quite large, and we overlooked the fact that &lt;strong&gt;CSS import order matters in Next.js&lt;/strong&gt; (see &lt;a href="https://nextjs.org/docs/app/getting-started/css#ordering-and-merging" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). &lt;br&gt;
As a result, two CSS rules with the same specificity ended up being swapped in the final generated CSS. &lt;/p&gt;

&lt;p&gt;At first glance, the bug was tricky to understand; it even seemed flaky when reviewing the updated snapshot PR:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fwv73vfkjlb6m6ndpsfy8.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.amazonaws.com%2Fuploads%2Farticles%2Fwv73vfkjlb6m6ndpsfy8.png" alt=" " width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, by inspecting the HTML diff in the Playwright report, we noticed that the footer layout had changed because Next.js bundled the CSS rules in a different order:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2F2nnkc7wes2zan7xjzn3q.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.amazonaws.com%2Fuploads%2Farticles%2F2nnkc7wes2zan7xjzn3q.png" alt=" " width="800" height="749"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 4: Updated carousel component for Mobile site causing an unintentional change on Desktop&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In another case, we updated a carousel component to improve mobile usability. However, this change inadvertently affected the desktop carousel, changing the cards’ dimensions.&lt;/p&gt;

&lt;p&gt;We noticed the issue because the PR created by the visual regression tool highlighted differences in the desktop view as well. We expected only one file to be changed (the mobile screen), but the PR showed two files changed.&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2Fkohuwixpae0p5gdkugru.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.amazonaws.com%2Fuploads%2Farticles%2Fkohuwixpae0p5gdkugru.png" alt=" " width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Upon investigation, we found that the desktop carousel cards were now taller than before:&lt;/p&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.amazonaws.com%2Fuploads%2Farticles%2F3h70enclmyu5tdufptd6.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.amazonaws.com%2Fuploads%2Farticles%2F3h70enclmyu5tdufptd6.png" alt=" " width="800" height="115"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How Did We Implement Visual Regression Testing?
&lt;/h2&gt;

&lt;p&gt;Using Playwright and its &lt;a href="https://playwright.dev/docs/test-snapshots" rel="noopener noreferrer"&gt;visual comparison feature&lt;/a&gt;, we implemented visual tests for our pages. &lt;br&gt;
For example, here is a test for our Login page:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&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;@only-visual 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="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slow&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;/login_form&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;waitForLoadState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle&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;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accedi con Google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Take a screenshot of the page&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenshot&lt;/span&gt; &lt;span class="o"&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;screenshot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Compare the screenshot with the baseline&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;screenshot&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatchSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login.png&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="na"&gt;maxDiffPixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&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;When implementing visual tests, you will likely need to fine tune for variables that can invalidate the visual comparison but are outside your control, such as advertising banners, marketing promos, or client-side calls like "recommended ads". &lt;br&gt;
We adopted two approaches:&lt;/p&gt;

&lt;p&gt;If a component is outside of our control and not part of the core product experience (for example, a marketing banner), we chose to temporarily hide it during visual testing:&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="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;addStyleTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    #sticky-cta-container { display: none !important; }
    .sticky-cta-bottom-anchor { display: none !important; }
  `&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;When the component’s size or layout was relevant, or when we wanted to display something in its place, we used &lt;a href="https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1-option-mask" rel="noopener noreferrer"&gt;Playwright's mask option&lt;/a&gt;.&lt;br&gt;
For example, we masked the Google Maps widget and replaced it with a simple placeholder square.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;googleIframe&lt;/span&gt; &lt;span class="o"&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;iframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yatmoMapIframe&lt;/span&gt; &lt;span class="o"&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;#map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenshot&lt;/span&gt; &lt;span class="o"&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;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;#layout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;animations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;googleIframe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;yatmoMapIframe&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;We also discovered a few additional tips that helped improve the reliability of our visual tests:&lt;/p&gt;

&lt;p&gt;Blocking Google Tag Manager (or similar scripts) prevents external resources from being fetched during tests, ensuring consistent screenshots across runs.&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;// Block Google Tag Manager to avoid loading external resources&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;route&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;gtm.js/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another interesting case we encountered involves images using the "lazy" loading attribute.&lt;br&gt;
Because these images load asynchronously, they can cause flaky results.&lt;/p&gt;

&lt;p&gt;Here’s an example of what that looks like:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fxpf4uzeprtrydz7miv85.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.amazonaws.com%2Fuploads%2Farticles%2Fxpf4uzeprtrydz7miv85.png" alt=" " width="800" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We implemented this helper to override the loading HTML attribute:&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="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;forceLoadLazyImages&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="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;return&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;evaluate&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;const&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelectorAll&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLImageElement&lt;/span&gt;&lt;span class="o"&gt;&amp;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;img[loading="lazy"]&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="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&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;eager&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="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The GitHub Action
&lt;/h3&gt;

&lt;p&gt;To automate our visual regression testing workflow, we use a GitHub Action; it runs automatically whenever a &lt;strong&gt;pull request is merged into the main branch&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The workflow performs a full end-to-end process, made of these key steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run visual regression tests&lt;/strong&gt;: the action launches Playwright. If any snapshot doesn’t match the baseline, the job flags that an update is needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save test results for reporting&lt;/strong&gt;: regardless of the outcome, all test reports are collected and stored as build artifacts. This allows merging results from all shards later into a single, comprehensive HTML report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update snapshots when differences are detected&lt;/strong&gt;: when visual mismatches are found, Playwright re-runs in update mode (&lt;code&gt;--update-snapshots&lt;/code&gt;), refreshing only the changed images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify and upload modified snapshots&lt;/strong&gt;: the Action inspects the Git diff to identify exactly which &lt;code&gt;.png&lt;/code&gt; files changed.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge all reports into a single HTML summary&lt;/strong&gt;: thanks to Playwright’s &lt;code&gt;merge-reports&lt;/code&gt; command, all blob reports from multiple shards are combined into one HTML report. The final report can be downloaded directly from the workflow artifacts and provides a clear visual summary of all changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open a PR with updated snapshots&lt;/strong&gt;: once all changes are ready, the workflow automatically creates a &lt;strong&gt;pull request&lt;/strong&gt; containing only the modified snapshots. 
&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.amazonaws.com%2Fuploads%2Farticles%2F5uo1v281ucqz2ax0znkn.png" alt="Visual regression PR example" width="800" height="437"&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notify the author via Slack&lt;/strong&gt;: finally, the Action sends a &lt;strong&gt;Slack notification&lt;/strong&gt; to the author of the merged PR.
&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.amazonaws.com%2Fuploads%2Farticles%2F8ko8td313svnyjm7196l.png" alt="Slack notification example" width="800" height="494"&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  A note on Git LFS
&lt;/h4&gt;

&lt;p&gt;Visual regression testing involves large binary files (mostly &lt;code&gt;.png&lt;/code&gt; images). To keep the repository lightweight and fast to clone, we use &lt;strong&gt;Git LFS (Large File Storage)&lt;/strong&gt;, which handles these files efficiently without bloating the main Git history.&lt;/p&gt;

&lt;p&gt;If you want to explore the complete YAML configuration, including all commands and conditions for each step, you can check it out here:&lt;br&gt;
&lt;a href="https://github.com/Subito-it/articles-code/blob/main/.github/workflows/visual-regression-analysis.yml" rel="noopener noreferrer"&gt;Full GitHub Action workflow on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt; we have developed a robust testing strategy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Unit tests for our components using Testing Library and Jest.&lt;/li&gt;
&lt;li&gt;Integration tests for all main user flows, avoiding mocks for backend services except for external providers.&lt;/li&gt;
&lt;li&gt;Recently, we added visual regression tests to support style and color updates, focusing on the most important pages and cases like the in-place login.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our internal process is simple: if an incident occurs, during the post-mortem we ask, "Could this have been prevented with a visual test?" If the answer is yes, we add a new visual test to our suite.&lt;/p&gt;

&lt;p&gt;If you are wondering whether we also take snapshots for single components, for now the answer is no, we only do this for entire pages. "Component level" snapshots are on our backlog, and we will likely use another tool for that (spoiler: Storybook).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Subito-it/articles-code/blob/main/.github/workflows/visual-regression-analysis.yml" rel="noopener noreferrer"&gt;Workflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Subito-it/articles-code/blob/main/.github/workflows/visual-e2e.yml" rel="noopener noreferrer"&gt;Github action&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Subito-it/articles-code/blob/main/visual-regression-example/test/visual/google.visual.spec.ts" rel="noopener noreferrer"&gt;Visual test&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>ci</category>
      <category>automation</category>
    </item>
    <item>
      <title>Evolving Our UI Library: From Custom Components to a Hybrid Radix Approach</title>
      <dc:creator>Chao Fang</dc:creator>
      <pubDate>Tue, 07 Oct 2025 13:37:00 +0000</pubDate>
      <link>https://forem.com/subito/evolving-our-ui-library-from-custom-components-to-a-hybrid-radix-approach-448f</link>
      <guid>https://forem.com/subito/evolving-our-ui-library-from-custom-components-to-a-hybrid-radix-approach-448f</guid>
      <description>&lt;p&gt;&lt;em&gt;How &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt;, Italy’s leading online classifieds platform, navigated the complexities of UI component libraries, from building everything in-house, to embracing open-source solutions.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Building and maintaining an internal UI component library is one of those challenges that seems straightforward until you're deep into it. &lt;/p&gt;

&lt;p&gt;At &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito&lt;/a&gt;, we've experienced this journey firsthand, evolving from building custom components in-house, to experimenting with external &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwindcss&lt;/a&gt;  (often simply referred to as Tailwind) based libraries like &lt;a href="https://github.com/leboncoin/spark-web" rel="noopener noreferrer"&gt;Spark&lt;/a&gt;, and ultimately adopting a hybrid approach centered around &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;This is the story of our lessons learned and the pragmatic decisions that shaped our current approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Our Own: The Custom Component Challenge
&lt;/h2&gt;

&lt;p&gt;Initially, we wanted to build our own components starting from native HTML elements as a foundation. &lt;/p&gt;

&lt;p&gt;We had specific design requirements and wanted full control over our component behavior while building on top of standard browser elements like &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;However, we quickly discovered that building truly accessible and cross-browser compatible components is far more complex than it appears on the surface.&lt;/p&gt;

&lt;p&gt;The main challenges we encountered included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browser inconsistencies&lt;/strong&gt;: What works perfectly in Chrome might behave differently in Safari or Firefox&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility requirements&lt;/strong&gt;: Implementing proper ARIA attributes, keyboard navigation, and screen reader support requires deep expertise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance optimization&lt;/strong&gt;: Ensuring components perform well across different devices and scenarios&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance overhead&lt;/strong&gt;: Every custom component becomes a long-term commitment that needs updates, bug fixes, and feature enhancements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We realized that maintaining these internal components was becoming expensive both in terms of development time and ongoing support.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tailwind-Based Library Experiment
&lt;/h2&gt;

&lt;p&gt;Recognizing the need for a more sustainable approach, we began looking for external solutions. &lt;/p&gt;

&lt;p&gt;Our first attempt involved adopting a Tailwind-based library called &lt;a href="https://github.com/leboncoin/spark-web" rel="noopener noreferrer"&gt;Spark&lt;/a&gt;, developed by our colleagues at &lt;a href="https://www.leboncoin.fr/" rel="noopener noreferrer"&gt;leboncoin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This seemed like a natural choice given that &lt;a href="https://www.leboncoin.fr/" rel="noopener noreferrer"&gt;leboncoin&lt;/a&gt; faced similar business requirements and design challenges, plus, the library offered a solid technical foundation and a comprehensive component set.&lt;/p&gt;

&lt;p&gt;Integrating this Tailwind-based library into our existing ecosystem proved more challenging than anticipated:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application-Specific Design&lt;/strong&gt;: It was designed primarily for complete applications rather than as a foundational library for building other UI libraries, as in our case, where we maintain a UI library &lt;a href="https://dev.to/subito/from-independent-microsites-to-context-driven-architecture-5166"&gt;used across many microsites&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailwind Adoption Challenge&lt;/strong&gt;: Adopting the Tailwind-based library forced us to integrate &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind&lt;/a&gt; into our web application, even though we hadn't officially approved it as a development tool.&lt;/p&gt;

&lt;p&gt;We were implicitly opening the doors to its usage throughout the codebase.&lt;br&gt;
This generated significant confusion among developers who were left wondering: "&lt;em&gt;Can I now use &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind&lt;/a&gt; utilities throughout the codebase, or should I continue writing CSS the traditional way?&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;Additionally, integrating &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind&lt;/a&gt; forced us to modify our existing CSS reset and baseline styles to make them coexist with Tailwindcss's own reset styles, adding another layer of complexity to our styling architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Path to Radix: Finding the Right Foundation
&lt;/h2&gt;

&lt;p&gt;After analyzing the complexities introduced by the Tailwind-based library approach, we decided to step back and reconsider our strategy. This led us to &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; as our core solution.&lt;/p&gt;

&lt;p&gt;It proved to be the ideal foundation for several reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maturity and Reliability&lt;/strong&gt;: is an incredibly mature library with a strong track record of stability and performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility First&lt;/strong&gt;: Built with accessibility as a core principle, it handles all the complex ARIA attributes, keyboard navigation, and screen reader support that we struggled with in our custom components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Community and Contribution&lt;/strong&gt;: The open-source nature allows us to contribute back to the project and benefit from a large community of developers and maintainers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-Friendly Development&lt;/strong&gt;: As a widely adopted library, it has excellent AI tooling support. Its popularity means that AI agents and coding assistants have extensive knowledge of &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; patterns, making it incredibly easy to build solutions on top of it with AI assistance.&lt;/p&gt;

&lt;p&gt;However, we noticed that &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; and our UX needs are not always perfectly aligned, so we decided to adopt a hybrid strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Hybrid Strategy: Radix + Selective Integration
&lt;/h2&gt;

&lt;p&gt;Thanks to &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; modular nature, we are not forced to adopt the entire library but can include only the components we truly need.  &lt;/p&gt;

&lt;p&gt;This gives us the flexibility to rely on &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; where it provides the most value, while integrating other specialized libraries whenever a specific component offers a better fit for our expectations.  &lt;/p&gt;

&lt;p&gt;In this way, we embrace a hybrid strategy that combines the robustness of &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; with the freedom to choose the most suitable solution for each case.&lt;/p&gt;

&lt;p&gt;For example, since &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; did not provide a dropdown that fully met our needs, we chose to integrate &lt;a href="https://react-select.com/" rel="noopener noreferrer"&gt;React Select&lt;/a&gt; instead of building our own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Our journey in building a UI library highlighted some key lessons:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creating truly accessible components in-house is costly and time consuming, so leveraging open-source solutions is often the smarter move.
&lt;/li&gt;
&lt;li&gt;If you’re not ready to fully commit to &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind CSS&lt;/a&gt;, avoid libraries tightly coupled with it, mixing approaches usually adds complexity instead of value.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.radix-ui.com/" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; proved to be a fantastic foundation: it offers mature, accessible, and behavior-driven components without dictating styling. &lt;em&gt;We read about the &lt;a href="https://www.radix-ui.com/primitives" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; maintainers moving on, but we agree with what the author of Shadcn pointed out &lt;a href="https://x.com/shadcn/status/1936082723904565435" rel="noopener noreferrer"&gt;here&lt;/a&gt;&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Mixing and matching the best tools from different open-source libraries, helped us strike a balance between control, quality, and maintainability.
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond technology, this journey also sparked a &lt;strong&gt;process shift&lt;/strong&gt;: while our UX/design team used to propose brand-new components, the focus is now on identifying the most suitable ones already available in the open-source ecosystem, contributing when possible, and adapting them to our design system.&lt;/p&gt;

&lt;p&gt;This change transforms the relationship between design and engineering:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;before&lt;/strong&gt;, the model was focused on creation,
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;now&lt;/strong&gt;, it’s increasingly about curation, research, and adaptation.
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the end of the day, the goal is simple: &lt;strong&gt;deliver great user experiences without reinventing every wheel along the way.&lt;/strong&gt;  &lt;/p&gt;

</description>
      <category>radix</category>
      <category>tailwindcss</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Microsites to Context-Driven Architecture: Lessons Learned</title>
      <dc:creator>Fabio Arcari</dc:creator>
      <pubDate>Fri, 05 Sep 2025 21:15:27 +0000</pubDate>
      <link>https://forem.com/subito/from-independent-microsites-to-context-driven-architecture-5166</link>
      <guid>https://forem.com/subito/from-independent-microsites-to-context-driven-architecture-5166</guid>
      <description>&lt;p&gt;&lt;em&gt;From 12 separate microsites to 6 context-specific ones: the web architecture powering &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt;, Italy’s leading online classifieds platform, and the lessons we learned from overengineering.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Four years ago, at &lt;a href="https://www.subito.it/" rel="noopener noreferrer"&gt;subito.it&lt;/a&gt;, we had fully embraced the microsite approach. &lt;br&gt;
Every page or small group of pages had its own repository, its own deploy pipeline, and its own infrastructure. &lt;br&gt;
It was the era of the article &lt;a href="https://adevinta.com/techblog/our-microsite-architecture/" rel="noopener noreferrer"&gt;"Our microsite architecture"&lt;/a&gt; published on Adevinta's tech blog, where we proudly described how we had "accelerated the frequency of releases and the independence of teams by applying a microsite approach."&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a Microsite?
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;microsite&lt;/strong&gt; is an architectural approach where individual pages or small groups of related pages are developed, deployed, and maintained as completely separate applications. &lt;br&gt;
Each microsite typically has its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository and codebase&lt;/li&gt;
&lt;li&gt;CI/CD pipeline and deployment process&lt;/li&gt;
&lt;li&gt;Infrastructure and hosting environment&lt;/li&gt;
&lt;li&gt;Technology stack (which can differ from other microsites)&lt;/li&gt;
&lt;li&gt;Team ownership and responsibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach contrasts with traditional monolithic applications where all pages are part of a single large codebase. &lt;br&gt;
Microsites aim to provide maximum team autonomy and deployment independence, allowing different teams to work on different parts of a website without interfering with each other.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Today, in 2025, we have an update on this architecture: we've found a better balance between agility and maintainability through continuous learning and optimization&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Microsite Era: When Everything Seemed Perfect
&lt;/h2&gt;

&lt;p&gt;The microsite approach we had adopted seemed like the ideal solution for our problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lean pipelines&lt;/strong&gt;: each microsite had a fast and independent deploy pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team autonomy&lt;/strong&gt;: each team could work on their own piece without interference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent releases&lt;/strong&gt;: no mutual blocking between teams&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over time, however, we started noticing the first issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Uncontrolled Repository Proliferation&lt;/strong&gt;, what had started as a strategy for important pages had begun to be applied to secondary pages as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed Technical Debt&lt;/strong&gt;, with 12 repositories to maintain, some pages started falling behind in dependency updates and security patches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessive Infrastructure Overhead&lt;/strong&gt;, each microsite required:

&lt;ul&gt;
&lt;li&gt;Monitoring configuration&lt;/li&gt;
&lt;li&gt;Alerting setup&lt;/li&gt;
&lt;li&gt;Pipeline maintenance&lt;/li&gt;
&lt;li&gt;CDN Configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The New Strategy: Context-Driven Microsites
&lt;/h2&gt;

&lt;p&gt;Instead of going completely back to a monolith (which would have reintroduced the problem of overly long pipelines), we chose a middle path: &lt;strong&gt;context-specific microsites&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principles of the New Architecture
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context First&lt;/strong&gt;: we group pages that share the same business domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear Ownership&lt;/strong&gt;: each microsite remains owned by a specific team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manageable Size&lt;/strong&gt;: large enough to justify the infrastructure, small enough to remain maintainable&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our new architecture consolidates related pages into context-specific groupings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public Content Hub&lt;/strong&gt;: All public content pages and SEO-relevant content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ad Insertion Flow&lt;/strong&gt;: The entire ad insertion workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication Service&lt;/strong&gt;: Authentication, login and sign-up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction Service&lt;/strong&gt;: The entire transaction ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid Options Service&lt;/strong&gt;: Paid services and premium options&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User Management Service&lt;/strong&gt;: Personal area and profile management&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Transition Process
&lt;/h2&gt;

&lt;p&gt;Our migration follows a structured approach with three main phases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1: Preparation and Setup&lt;/strong&gt;&lt;br&gt;
The initial phase focuses on preparing the target microsite to receive the migrated functionality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set up dedicated layouts and components in the target architecture&lt;/li&gt;
&lt;li&gt;Import and adapt components to the consolidated codebase&lt;/li&gt;
&lt;li&gt;Update environment variables and configuration management&lt;/li&gt;
&lt;li&gt;Establish monitoring and alerting for the new consolidated service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Phase 2: Quality Assurance and Testing&lt;/strong&gt;&lt;br&gt;
Before going live, we conduct comprehensive checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Execute automated end-to-end (E2E) and manual tests&lt;/li&gt;
&lt;li&gt;Validate third-party integrations (e.g., GTM)&lt;/li&gt;
&lt;li&gt;Verify page headers, caching policies, and security configurations&lt;/li&gt;
&lt;li&gt;Confirm monitoring and metrics collection are correctly implemented&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Phase 3: Production Migration and Cleanup&lt;/strong&gt;&lt;br&gt;
The final phase involves the actual cutover and decommissioning of legacy systems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gradual Rollout:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Route traffic progressively from legacy to consolidated microsite&lt;/li&gt;
&lt;li&gt;Monitor performance and error rates during the transition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Legacy Cleanup:&lt;/strong&gt;&lt;br&gt;
Following our established Service End-of-Life procedures, we systematically decommission the old microsite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Destroy monitoring, alerts, and dashboards&lt;/li&gt;
&lt;li&gt;Archive repositories and remove deployment configurations&lt;/li&gt;
&lt;li&gt;Clean up infrastructure resources (databases, IAM roles, etc.)&lt;/li&gt;
&lt;li&gt;Update routing and add redirects where necessary&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;p&gt;Our journey from microsites to a more consolidated approach wasn't a failure of microsite architecture, but rather a maturation of our understanding of when and how to apply it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we learned:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Architecture must evolve with business and team needs&lt;/li&gt;
&lt;li&gt;Not everything needs to be a microservice/microsite - like any architectural pattern, it has its trade-offs&lt;/li&gt;
&lt;li&gt;Context is fundamental, group by business context, not technical convenience&lt;/li&gt;
&lt;li&gt;Pages that belong to the same user flow should probably stay together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Today, with our 6 context-driven microsites, we've found a balance that allows us to maintain team autonomy and deployment speed while significantly reducing maintenance overhead.&lt;/p&gt;

&lt;p&gt;The path to perfect architecture doesn't exist, but the path to ever-improving architecture does.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
