<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Long Ho</title>
    <link>https://longlho.github.io</link>
    <description>Technical notes on frontend systems, build graphs, and product infrastructure.</description>
    <language>en</language>
    <atom:link href="https://longlho.github.io/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Thu, 21 May 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>The Metaframework Is Dead</title>
      <link>https://longlho.github.io/posts/the-metaframework-is-dead/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/the-metaframework-is-dead/</guid>
      <description>The title is a little dramatic. I do not mean nobody should use Next, Remix, Astro, SvelteKit, or whatever comes next.</description>
      <content:encoded>
        <![CDATA[<p>The title is a little dramatic. I do not mean nobody should use Next, Remix, Astro, SvelteKit, or whatever comes next.</p>
<p>I mean the metaframework stopped being the most interesting boundary in the system.</p>
<p>For a long time, the deal was great: web apps had too many decisions, and a metaframework showed up with one coherent answer. Routing, rendering, data loading, server code, bundling, deployment, caching, images, CSS, and environment config all got pulled into one mental model.</p>
<p>That helped.</p>
<p>But the more the framework owns, the more the actual shape of the product disappears into runtime behavior, compiler transforms, generated manifests, and deployment adapter rules. At some point the app is not simpler. It is just harder to see.</p>
<p>The thing that changes the tradeoff now is AI agents.</p>
<p>Codex, Claude Code, and similar tools are good at producing explicit scaffolding. They can create the route, register it, wire the test, update the manifest, and follow the repo&#39;s local patterns. That makes the next useful move less about hiding structure behind another runtime abstraction, and more about generating clear code that teams can own.</p>
<h2>The Old Deal</h2>
<p>The old deal was runtime consolidation.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  app[&quot;Application&quot;] --&gt; framework[&quot;Metaframework runtime&quot;]
  framework --&gt; router[&quot;Router&quot;]
  framework --&gt; data[&quot;Data loading&quot;]
  framework --&gt; server[&quot;Server functions&quot;]
  framework --&gt; bundler[&quot;Bundler integration&quot;]
  framework --&gt; deploy[&quot;Deploy adapter&quot;]
  framework --&gt; cache[&quot;Cache behavior&quot;]</pre></figure><p>That shape is seductive. Need a route? Put a file in the right directory. Need a mutation? Export a server function. Need caching? Use the blessed helper. Need deployment? Pick the adapter.</p>
<p>You get a lot done quickly because the framework makes big choices feel small.</p>
<p>The cost shows up later. The route is not just a file. The server function is not just a function. The cache is not just a cache. They are all pieces of a larger runtime contract, and that contract may be partly generated, partly inferred, and partly specific to one hosting path.</p>
<p>When everything important is hidden in framework convention, understanding the app means understanding the framework&#39;s private model of the app.</p>
<h2>The Runtime Got Too Important</h2>
<p>Modern metaframeworks do not just render pages. They decide where code runs, split bundles, serialize server references, infer static and dynamic behavior, wrap request caches, generate manifests, and map one source tree onto several runtime targets.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  source[&quot;Source tree&quot;] --&gt; compiler[&quot;Framework compiler&quot;]
  compiler --&gt; manifest[&quot;Generated manifests&quot;]
  compiler --&gt; client[&quot;Client bundles&quot;]
  compiler --&gt; server[&quot;Server bundles&quot;]
  manifest --&gt; runtime[&quot;Framework runtime&quot;]
  client --&gt; runtime
  server --&gt; runtime
  runtime --&gt; platform[&quot;Host platform&quot;]</pre></figure><p>Some of that belongs at runtime. Requests have to be served. Navigation has to work. Streaming has to run somewhere.</p>
<p>But a lot of the important shape can be decided before the app runs: route tables, API contracts, server reference maps, feature boundaries, entrypoints, asset graphs, environment policy, and deploy metadata.</p>
<p>Those are better as build outputs than vibes.</p>
<p>If the build can generate them, the build can also check them, diff them, document them, and fail early when they drift.</p>
<h2>Build Time Is The Better Boundary</h2>
<p>The healthier version looks more like this:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  source[&quot;Application source&quot;] --&gt; buildGraph[&quot;Build graph&quot;]
  buildGraph --&gt; generated[&quot;Generated app structure&quot;]
  buildGraph --&gt; checks[&quot;Static checks&quot;]
  buildGraph --&gt; bundles[&quot;Runtime bundles&quot;]
  buildGraph --&gt; docs[&quot;Generated maps&quot;]

  generated --&gt; app[&quot;Owned application code&quot;]
  checks --&gt; app
  bundles --&gt; deploy[&quot;Deployable artifacts&quot;]</pre></figure><p>This changes the relationship with the framework.</p>
<p>The framework can still be powerful, but it is no longer the box the product lives inside. It becomes a generator, compiler, checker, and dev server for an app whose structure is visible.</p>
<p>That visibility matters. Reviewers can read a diff. Tests can point at concrete files. Boundary rules can run before deploy. A future teammate can inspect the route map or API contract without learning a pile of runtime inference rules first.</p>
<h2>Agents Change The Cost Model</h2>
<p>This is the part that feels different from five years ago.</p>
<p>The old reason to prefer deep framework abstraction was that explicit structure was expensive. Nobody wanted to write the boring glue. So the framework hid it.</p>
<p>AI agents make that bargain less compelling. Codex or Claude Code can handle the glue. They can look at the repo, copy the local convention, add the obvious files, and adjust the nearby tests.</p>
<p>That does not mean the agent should create more magic. It means the agent can afford to create more ordinary code.</p>
<h2>Verbose Scaffolding Is Back</h2>
<p>We spent years treating boilerplate as the enemy.</p>
<p>Some boilerplate is bad. Nobody wants to hand-wire the same fifteen files every time they add a page. Nobody wants copied data loaders that quietly drift.</p>
<p>But explicit scaffolding is different from busywork.</p>
<p>I would rather have:</p>
<ul>
<li>a real route module than a hidden route record</li>
<li>a named server endpoint than an opaque server reference</li>
<li>a visible cache policy than cache behavior inferred from callsite magic</li>
<li>an import boundary enforced by the build than a package name pretending to be architecture</li>
</ul>
<p>The old objection was fair: explicit code costs time.</p>
<p>That objection is weaker now. Agents and generators are good at boring code. They can leave behind ordinary files instead of another layer of framework indirection.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  intent[&quot;Developer intent&quot;] --&gt; agent[&quot;AI agent or generator&quot;]
  repo[&quot;Repo conventions&quot;] --&gt; agent
  buildGraph[&quot;Build graph&quot;] --&gt; agent
  agent --&gt; files[&quot;Concrete files&quot;]
  files --&gt; review[&quot;Human review&quot;]
  review --&gt; app[&quot;Owned app structure&quot;]</pre></figure><p>The important part is the output. It should not be magic. It should be code the team can read, change, delete, and blame.</p>
<p>That is the part agents make newly cheap: not less structure, but more explicit structure with less typing.</p>
<h2>Default To Eject Mode</h2>
<p>The best default is not &quot;never use a framework.&quot; That is silly.</p>
<p>The best default is: use the framework as if you already ejected.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  framework[&quot;Framework&quot;] --&gt; generate[&quot;Generate&quot;]
  framework --&gt; check[&quot;Check&quot;]
  framework --&gt; compile[&quot;Compile&quot;]
  framework --&gt; serve[&quot;Serve&quot;]

  app[&quot;Application&quot;] --&gt; routes[&quot;Routes&quot;]
  app --&gt; api[&quot;API contracts&quot;]
  app --&gt; data[&quot;Data clients&quot;]
  app --&gt; cache[&quot;Cache policy&quot;]
  app --&gt; deploy[&quot;Deployment shape&quot;]

  generate --&gt; app
  check --&gt; app
  compile --&gt; app
  serve --&gt; deploy</pre></figure><p>Eject mode does not mean giving up good tools. It means the framework helps create and maintain the application shape instead of hiding it.</p>
<p>The route tree can be conventional, but the route table should be visible. Server functions can be ergonomic, but the API boundary should be named. Caching can have defaults, but the policy should be inspectable. Deployment can be integrated, but the artifact should be understandable.</p>
<p>That is where &quot;more boilerplate&quot; starts to mean &quot;less magic.&quot;</p>
<h2>The New Deal</h2>
<p>The metaframework is dead as the central abstraction.</p>
<p>Not because routing, rendering, dev servers, or deployment adapters stopped mattering. They still matter a lot.</p>
<p>But the next jump is not another layer of runtime cleverness. It is build-time generation plus explicit ownership, with AI agents handling the scaffolding work that used to make explicit architecture feel too expensive.</p>
<p>Use the framework to compile, optimize, and serve. Use the build graph to make the product shape concrete. Use agents like Codex and Claude Code to create the boring structure. Keep the important boundaries visible.</p>
<p>Start with the code you would want after the abstraction leaks, and let the tools help you keep it boring.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 21 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>A Package Is a Distribution Boundary</title>
      <link>https://longlho.github.io/posts/a-package-is-a-distribution-boundary/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/a-package-is-a-distribution-boundary/</guid>
      <description>There is a habit in JavaScript monorepos that feels natural at first:</description>
      <content:encoded>
        <![CDATA[<p>There is a habit in JavaScript monorepos that feels natural at first:</p>
<blockquote>
<p>New folder, new <code>package.json</code>, new package name.</p>
</blockquote>
<p>It is tidy. It makes imports look official. It gives a team something that feels like a boundary.</p>
<p>The trouble is that it quietly mixes up two different things:</p>
<ul>
<li>a folder of code with an owner</li>
<li>an npm package with a public name</li>
</ul>
<p>Those are not the same thing, and treating them as the same thing makes the repository harder for both people and tools to understand.</p>
<p>The common pattern is to put a <code>package.json</code> in every meaningful folder, give it a name like <code>@acme/payments</code>, add it to a workspace, and import it from other code:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>calculateTax<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;@acme/payments&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>That import looks clean. It also hides a lot of machinery for code that never leaves the repository.</p>
<p>If the package is not published, installed by another repository, or consumed through a stable external contract, the package name is not really a product boundary. It is an alias with ceremony. The repository now has two naming systems: the filesystem and the package graph.</p>
<h2>The Package Name Trap</h2>
<p>The filesystem already has a name for the code:</p>
<pre class="code-block"><code class="language-text">packages<span class="syntax-punctuation">/</span>payments<span class="syntax-punctuation">/</span>calculate<span class="syntax-punctuation">-</span>tax<span class="syntax-punctuation">.</span>ts</code></pre><p>The package manifest invents another one:</p>
<pre class="code-block"><code class="language-json"><span class="syntax-punctuation">{</span>
  <span class="syntax-string">&quot;name&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;@acme/payments&quot;</span>
<span class="syntax-punctuation">}</span></code></pre><p>Once both names exist, every tool has to answer questions the code did not really need to ask:</p>
<ul>
<li>Which folder owns <code>@acme/payments</code>?</li>
<li>Is that name a workspace package, an installed dependency, or both?</li>
<li>Does the import resolve to source, compiled output, or a package entrypoint?</li>
<li>Which <code>exports</code>, <code>types</code>, <code>main</code>, <code>module</code>, and <code>sideEffects</code> fields matter for this tool?</li>
<li>Does the bundler see the same graph as TypeScript, tests, lint, and the build system?</li>
</ul>
<p>That indirection is worth paying for public packages because public packages need a distribution contract. It is much less convincing for private internal code.</p>
<p>It gets worse when internal packages use root entrypoints as the normal way to import anything:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>Button<span class="syntax-punctuation">,</span> Modal<span class="syntax-punctuation">,</span> TextField<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;@acme/ui&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>That entrypoint is usually a barrel file. It is pleasant to read, but the tools still have to resolve the re-export graph behind it. Marvin Hagemeister&#39;s <a href="https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-7/">&quot;The barrel file debacle&quot;</a> is the canonical writeup here. The <a href="https://nextjs.org/docs/app/guides/local-development#barrel-files">Next.js local development guide</a> now explicitly warns that barrel files can slow builds because the compiler has to parse them to check for side effects. Atlassian also published a large migration story, <a href="https://www.atlassian.com/blog/atlassian-engineering/faster-builds-when-removing-barrel-files">&quot;How We Achieved 75% Faster Builds by Removing Barrel Files&quot;</a>, where removing barrel files improved TypeScript, test selection, and CI performance.</p>
<p>The pattern is familiar:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  import[&quot;import from a short name&quot;] --&gt; alias[&quot;package name or barrel&quot;]
  alias --&gt; resolver[&quot;resolver work&quot;]
  resolver --&gt; source[&quot;actual source file&quot;]
  resolver --&gt; extra[&quot;extra files in the graph&quot;]</pre></figure><p>A short import is not free if every tool has to chase a larger graph to understand it.</p>
<p>Public package entrypoints are still useful. A published library should have a small, documented API. But that is the edge of the repository. It does not need to become the default way internal files talk to each other.</p>
<h2>Relative Imports Are Not Great Either</h2>
<p>The obvious alternative is relative imports:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>calculateTax<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;../../payments/calculate-tax.js&quot;</span><span class="syntax-punctuation">;</span>
<span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>formatMoney<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;../../../core/money/format.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>Relative imports are honest. They do not need package manager magic, and they can be resolved from the importing file alone.</p>
<p>They are also pretty annoying in a large codebase.</p>
<p>Move a file and the import path changes. Move a folder and all of its consumers may need edits. A dependency from checkout to payments is encoded as <code>../../..</code>, which is technically precise and semantically useless.</p>
<p>Relative paths make local file movement expensive and make architecture harder to read. They answer &quot;how do I walk the directory tree from here?&quot; when the reviewer wants to know &quot;which module does this depend on?&quot;</p>
<p>So I do not think the choice is really package names versus relative paths. The nicer target is absolute imports over the repository&#39;s source tree.</p>
<h2>Use One Root Import Space</h2>
<p>For Node and TypeScript projects, the cleanest version I have found is a single private root <code>package.json</code> with a <a href="https://nodejs.org/api/packages.html#imports"><code>package.json#imports</code></a> map. TypeScript supports these package imports in <code>node16</code>, <code>nodenext</code>, and <code>bundler</code> module resolution modes:</p>
<pre class="code-block"><code class="language-json"><span class="syntax-punctuation">{</span>
  <span class="syntax-string">&quot;private&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-keyword">true</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;type&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;module&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;imports&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-punctuation">{</span>
    <span class="syntax-string">&quot;#apps/*&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./apps/*&quot;</span><span class="syntax-punctuation">,</span>
    <span class="syntax-string">&quot;#packages/*&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./packages/*&quot;</span><span class="syntax-punctuation">,</span>
    <span class="syntax-string">&quot;#tools/*&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./tools/*&quot;</span>
  <span class="syntax-punctuation">}</span>
<span class="syntax-punctuation">}</span></code></pre><p>Then source imports by repository path:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>calculateTax<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/payments/calculate-tax.js&quot;</span><span class="syntax-punctuation">;</span>
<span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>formatMoney<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/core/money/format.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>This buys a few things at once.</p>
<p>The import path stays stable when the importing file moves. The path says what the dependency is, not how many <code>..</code> hops it takes to reach it. The resolver has one root map instead of one package manifest per internal folder. TypeScript, Node, tests, lint, and bundlers can share the same naming convention instead of each growing a slightly different alias system.</p>
<p>It also matches how many other language ecosystems feel in practice. Go imports by module path plus directory. Rust code commonly uses crate-rooted paths like <code>crate::feature::module</code>. Python packages usually import from a package root instead of walking the tree from every file. Java and Kotlin package names are absolute namespaces.</p>
<p>The point is not that JavaScript should pretend to be Go or Rust. The point is simpler: internal code should have a stable absolute address that maps back to the source tree.</p>
<h2>Boundaries Are Not Resolution</h2>
<p>The usual objection is:</p>
<blockquote>
<p>If everyone can import any source file, how do we keep boundaries?</p>
</blockquote>
<p>That is the right question. Package names are just a clumsy answer.</p>
<p>Resolution and visibility are different jobs. Absolute imports should tell tools where a file is. Boundary rules should tell developers whether that dependency is allowed.</p>
<p>Go has a useful convention here. A directory named <a href="https://go.dev/doc/go1.4#internalpackages"><code>internal</code></a> can only be imported by code in the parent tree. In a TypeScript monorepo, the same convention is easy to read:</p>
<pre class="code-block"><code class="language-text">packages<span class="syntax-punctuation">/</span>
  checkout<span class="syntax-punctuation">/</span>
    checkout<span class="syntax-punctuation">-</span>page<span class="syntax-punctuation">.</span>tsx
    internal<span class="syntax-punctuation">/</span>
      price<span class="syntax-punctuation">-</span>breakdown<span class="syntax-punctuation">.</span>ts
      shipping<span class="syntax-punctuation">-</span>rules<span class="syntax-punctuation">.</span>ts
  payments<span class="syntax-punctuation">/</span>
    charge<span class="syntax-punctuation">-</span>card<span class="syntax-punctuation">.</span>ts</code></pre><p>This import should be allowed:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>renderPriceBreakdown<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/checkout/internal/price-breakdown.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>from code under <code>packages/checkout</code>.</p>
<p>The same import should be rejected from <code>packages/payments</code>, <code>packages/search</code>, or an app that happens to know the file exists.</p>
<p>That enforcement can live in lint rules, build-system visibility, dependency-cruiser, Nx module boundaries, a custom ESLint rule, Bazel visibility, or a small repository-specific checker. The rule needs to compare the importer path with the imported path. A package alias cannot express that by itself.</p>
<p>This split keeps the system honest:</p>
<ul>
<li>Import maps make files addressable.</li>
<li>Boundary checks decide which addresses are allowed.</li>
<li>Package manifests describe artifacts that leave the repository.</li>
</ul>
<p>When those jobs are separate, each one becomes easier to reason about.</p>
<h2>package.json Is for Public Artifacts</h2>
<p>I am not arguing that <code>package.json</code> is bad. It is good at describing an npm package. That is its job.</p>
<p>Use one when the code is consumed outside the monorepo:</p>
<ul>
<li>a public npm package</li>
<li>a private package published to an internal registry</li>
<li>an SDK installed by another repository</li>
<li>a plugin package loaded by a package manager</li>
<li>a package with semver, changelog, peer dependencies, and an external API</li>
</ul>
<p>In that world, <code>package.json</code> fields are real product surface:</p>
<pre class="code-block"><code class="language-json"><span class="syntax-punctuation">{</span>
  <span class="syntax-string">&quot;name&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;@acme/payments-sdk&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;version&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;3.2.0&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;type&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;module&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;exports&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-punctuation">{</span>
    <span class="syntax-string">&quot;.&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./index.js&quot;</span><span class="syntax-punctuation">,</span>
    <span class="syntax-string">&quot;./testing&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./testing.js&quot;</span>
  <span class="syntax-punctuation">},</span>
  <span class="syntax-string">&quot;types&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;index.d.ts&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;peerDependencies&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-punctuation">{</span>
    <span class="syntax-string">&quot;react&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;^19.0.0&quot;</span>
  <span class="syntax-punctuation">}</span>
<span class="syntax-punctuation">}</span></code></pre><p>That is a real distribution contract. Consumers outside the repository should not know your source tree. They should know the public package name and the public subpaths you support.</p>
<p>Internal code is different. If the only consumer is the same repository, a package manifest often turns into ritual:</p>
<ul>
<li>fake versions</li>
<li>fake package names</li>
<li>fake publish boundaries</li>
<li>dependency lists that duplicate build metadata</li>
<li>generated <code>exports</code> fields for code that is never exported</li>
<li>package manager linking for code that could be resolved directly</li>
</ul>
<p>Internal package manifests can work. They are just often solving the wrong problem.</p>
<h2>FormatJS Is a Useful Example</h2>
<p>The <a href="https://github.com/formatjs/formatjs">FormatJS repository</a> is a nice example because it is both a real public library monorepo and a modern TypeScript codebase.</p>
<p>The repository publishes real packages such as <code>@formatjs/intl</code>, <code>@formatjs/cli</code>, <code>@formatjs/icu-messageformat-parser</code>, <code>intl-messageformat</code>, and <code>react-intl</code>. Those packages deserve package manifests because people install them from outside the repo. They have names, versions, entrypoints, peer dependencies, and compatibility expectations.</p>
<p>The interesting bit is that the <a href="https://github.com/formatjs/formatjs/blob/main/package.json">root package</a> is private and defines an internal import map:</p>
<pre class="code-block"><code class="language-json"><span class="syntax-punctuation">{</span>
  <span class="syntax-string">&quot;name&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;formatjs-repo&quot;</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;private&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-keyword">true</span><span class="syntax-punctuation">,</span>
  <span class="syntax-string">&quot;imports&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-punctuation">{</span>
    <span class="syntax-string">&quot;#packages/*&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;./packages/*&quot;</span>
  <span class="syntax-punctuation">}</span>
<span class="syntax-punctuation">}</span></code></pre><p>That lets source files import by repository path instead of inventing package names for every internal source boundary:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span><span class="syntax-keyword">type</span> MessageDescriptor<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/intl/types.js&quot;</span><span class="syntax-punctuation">;</span>
<span class="syntax-keyword">export</span> <span class="syntax-punctuation">*</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/intl/types.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p><code>react-intl</code> does the same thing for its own internals:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span>
  createFormattedComponent<span class="syntax-punctuation">,</span>
  createFormattedDateTimePartsComponent<span class="syntax-punctuation">,</span>
<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#packages/react-intl/components/createFormattedComponent.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>At the same time, <code>react-intl</code> also imports and re-exports the public <code>@formatjs/intl</code> package:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">export</span> <span class="syntax-punctuation">{</span>
  createIntlCache<span class="syntax-punctuation">,</span>
  <span class="syntax-keyword">type</span> FormatDateOptions<span class="syntax-punctuation">,</span>
  <span class="syntax-keyword">type</span> MessageDescriptor<span class="syntax-punctuation">,</span>
<span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;@formatjs/intl&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>That distinction is the whole point.</p>
<p>FormatJS still has package manifests for publishable artifacts. <a href="https://github.com/formatjs/formatjs/blob/main/packages/intl/package.json"><code>@formatjs/intl</code></a> has <code>name</code>, <code>version</code>, <code>exports</code>, <code>types</code>, and <code>workspace:*</code> dependencies. <a href="https://github.com/formatjs/formatjs/blob/main/packages/react-intl/package.json"><code>react-intl</code></a> has its own manifest, peer dependencies, and public subpath exports. Those are real package boundaries because external users install them.</p>
<p>But internal source references do not have to pretend every folder is an npm package. The root <code>#packages/*</code> map gives the repository a stable internal address space.</p>
<p>FormatJS is not an argument against package manifests. It is an argument for putting them at the distribution boundary, not every source boundary.</p>
<p>There is one nuance: public package entrypoints still look like barrels. <a href="https://github.com/formatjs/formatjs/blob/main/packages/intl/index.ts"><code>packages/intl/index.ts</code></a> re-exports the API for <code>@formatjs/intl</code>, and <a href="https://github.com/formatjs/formatjs/blob/main/packages/react-intl/index.ts"><code>packages/react-intl/index.ts</code></a> does the same for React users. That is reasonable because a public package needs a public API.</p>
<p>The mistake would be forcing all internal code to go through those public entrypoints when it really depends on a specific source module.</p>
<h2>Pick Tooling That Does Not Require Fake Packages</h2>
<p>The build system should let you create a code boundary without creating an npm package.</p>
<p>You should be able to say:</p>
<ul>
<li>these files form a unit</li>
<li>these are its runtime dependencies</li>
<li>these are its test dependencies</li>
<li>this is how to typecheck it</li>
<li>this is how to test it</li>
<li>these imports are allowed</li>
<li>these imports are forbidden</li>
</ul>
<p>None of that inherently requires a <code>package.json</code>.</p>
<p>Some tools make package manifests the only project discovery mechanism. That nudges teams into creating fake npm packages just to get typechecking, tests, cache keys, ownership, or dependency boundaries. At small scale, this feels fine. At large scale, the repository accumulates a second filesystem made out of package names.</p>
<p>Better tooling lets packages be arbitrary source units. Bazel targets can do this. Custom TypeScript project generators can do this. Nx can do parts of this with project configuration and module boundary rules. A repo-specific lint or dependency checker can do this. The exact tool matters less than the capability.</p>
<p>The workflow should feel more like this:</p>
<pre class="code-block"><code class="language-text">create folder <span class="syntax-punctuation">-&gt;</span> add source <span class="syntax-punctuation">-&gt;</span> add test target <span class="syntax-punctuation">-&gt;</span> <span class="syntax-keyword">import</span> by absolute path <span class="syntax-punctuation">-&gt;</span> enforce boundaries</code></pre><p>and less like this:</p>
<pre class="code-block"><code class="language-text">create folder <span class="syntax-punctuation">-&gt;</span> invent package name <span class="syntax-punctuation">-&gt;</span> add package<span class="syntax-punctuation">.</span>json <span class="syntax-punctuation">-&gt;</span> update workspace <span class="syntax-punctuation">-&gt;</span> teach every tool how to resolve the fake package <span class="syntax-punctuation">-&gt;</span> <span class="syntax-keyword">import</span> through entrypoint <span class="syntax-punctuation">-&gt;</span> fight barrels later</code></pre><h2>The Rule</h2>
<p>Use package names for code that leaves the monorepo.</p>
<p>Use absolute root imports for code that lives inside the monorepo.</p>
<p>Use <code>internal</code> folders and lint/build visibility for private implementation details.</p>
<p>Use public package entrypoints only at real public package boundaries.</p>
<p>That gives the repository one source address space, real distribution contracts where they matter, and fewer layers of indirection for every tool that has to understand the graph.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>A Cross-Platform Web Runtime Is A Product Boundary</title>
      <link>https://longlho.github.io/posts/a-cross-platform-web-runtime-is-a-product-boundary/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/a-cross-platform-web-runtime-is-a-product-boundary/</guid>
      <description>Web apps usually start with a simple assumption: the browser is the platform.</description>
      <content:encoded>
        <![CDATA[<p>Web apps usually start with a simple assumption: the browser is the platform.</p>
<p>That works for a while. Then the same product needs to run in more than one browser, inside an embedded third-party surface, in a desktop shell, in mobile webviews, or across a few generations of browser support.</p>
<p>At that point, &quot;the browser&quot; is not one platform anymore. It is a family of runtimes with different APIs, storage behavior, networking rules, telemetry paths, and localization support.</p>
<p>The mistake is letting every feature handle those differences on its own.</p>
<h2>Application Anatomy</h2>
<p>Most web apps end up with layers, even if nobody names them at first.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TB
  app[&quot;Application&quot;]

  subgraph featureLayer[&quot;Product features&quot;]
    search[&quot;Feature A&quot;]
    settings[&quot;Feature B&quot;]
    checkout[&quot;Feature C&quot;]
  end

  subgraph coreLayer[&quot;Core components&quot;]
    design[&quot;Design system&quot;]
    router[&quot;Router&quot;]
    sharedUi[&quot;Shared UI&quot;]
    domain[&quot;Domain helpers&quot;]
  end

  subgraph runtimeLayer[&quot;Web platform runtime&quot;]
    direction LR
    io[&quot;IO&quot;]
    storage[&quot;Storage&quot;]
    telemetry[&quot;Telemetry&quot;]
    analytics[&quot;Analytics&quot;]
    l10n[&quot;Localization&quot;]
    config[&quot;Config&quot;]
    polyfills[&quot;Polyfills&quot;]
  end

  subgraph nativeLayer[&quot;Native capabilities&quot;]
    browser[&quot;Browser APIs&quot;]
    webview[&quot;Webview APIs&quot;]
    desktop[&quot;Desktop shell APIs&quot;]
  end

  app --&gt; featureLayer
  featureLayer --&gt; coreLayer
  coreLayer --&gt; runtimeLayer
  runtimeLayer --&gt; nativeLayer</pre></figure><p>The application owns product composition. Product features own user workflows. Core components own reusable product building blocks.</p>
<p>The web platform runtime owns the messy part: making the same capability behave consistently across the environments where the product runs.</p>
<p>That layer is easy to underinvest in because it rarely ships a feature by itself. When it is missing, though, the cost shows up everywhere else.</p>
<h2>Code Sharing Needs Rules</h2>
<p>Sharing everything sounds efficient. In practice, it often produces abandoned utilities, ambiguous ownership, and packages nobody wants to delete.</p>
<p>Sharing nothing is not better. It creates duplicated tracking code, duplicated storage helpers, duplicated fetch wrappers, duplicated polyfill decisions, and a different failure mode in every feature.</p>
<p>The useful middle is to decide which layers are meant to be shared.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  app[&quot;Application code\nusually app-specific&quot;]
  feature[&quot;Product feature\nshareable at feature boundary&quot;]
  core[&quot;Core components\nshared intentionally&quot;]
  runtime[&quot;Web platform runtime\nshared by default&quot;]
  internals[&quot;Feature internals\nnot shared&quot;]
  tiny[&quot;Random tiny helpers\nnot shared by default&quot;]

  runtime --&gt; core
  core --&gt; feature
  feature --&gt; app
  feature --&gt; internals
  app --&gt; tiny

  classDef shared fill:#e8f5e9,stroke:#2e7d32,color:#111;
  classDef private fill:#fff3e0,stroke:#ef6c00,color:#111;
  class runtime,core,feature shared;
  class internals,tiny private;</pre></figure><p>The rule I like is: share platform, core components, and deliberate product features. Do not share random internal components just because two places happen to want them today.</p>
<p>That rule needs enforcement. Import boundaries, package visibility, lint rules, ownership, and dependency checks matter because &quot;please be thoughtful&quot; does not scale.</p>
<h2>What The Runtime Owns</h2>
<p>A web platform runtime is not a <code>utils</code> package. It owns the stuff every serious feature eventually needs.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  runtime[&quot;Web platform runtime&quot;]

  runtime --&gt; ecma[&quot;Language and browser features\npolyfills, CSS support, browser targeting&quot;]
  runtime --&gt; io[&quot;IO\nfetch wrapper, auth headers, retries, tracing&quot;]
  runtime --&gt; storage[&quot;Persistence\nquota, local cache, storage adapters&quot;]
  runtime --&gt; telemetry[&quot;Telemetry\nRUM, errors, client logs&quot;]
  runtime --&gt; analytics[&quot;Analytics\ninteraction metadata, funnels, attribution&quot;]
  runtime --&gt; l10n[&quot;Localization\nlocale data, translations, formatting&quot;]
  runtime --&gt; config[&quot;Config\nruntime config, feature flags, environment dimensions&quot;]</pre></figure><p>Each box looks boring until it is implemented five different ways.</p>
<h2>Polyfills Are A Delivery System</h2>
<p>Polyfills are not just a list of imports at the top of the app.</p>
<p>Different browsers need different polyfills. Different locales need different data. Mobile Safari has different gaps from Chromium. Embedded runtimes and desktop shells may support some native APIs but not others. Localization data can be especially large because it often pulls from Unicode CLDR data.</p>
<p>A platform layer should decide:</p>
<ul>
<li>which browser and runtime versions are supported</li>
<li>which APIs can be polyfilled</li>
<li>which APIs must be banned or guarded</li>
<li>which locale data is loaded for which user</li>
<li>how polyfill bundles are tested</li>
</ul>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  request[&quot;Page request&quot;] --&gt; detect[&quot;Detect runtime\nbrowser, version, locale, shell&quot;]
  detect --&gt; policy[&quot;Capability policy&quot;]
  policy --&gt; js[&quot;JS polyfills&quot;]
  policy --&gt; css[&quot;CSS support checks&quot;]
  policy --&gt; locale[&quot;Locale data&quot;]
  js --&gt; boot[&quot;App bootstrap&quot;]
  css --&gt; boot
  locale --&gt; boot</pre></figure><p>The important bit is that each feature should not make this up on the fly. Features should write against a known platform contract.</p>
<p>There is useful prior art here. Dropbox has historically shipped targeted bootstrap bundles for different browser/locale combinations. Yahoo-style dynamic polyfill services are another version of the same idea: do not make every client pay for every compatibility layer. Resolve the runtime shape, then send the smallest safe set of patches.</p>
<h2>IO Should Be A Platform API</h2>
<p>Network code is one of the first places where cross-platform assumptions break.</p>
<p>The browser has <code>fetch</code>. An embedded third-party surface may need an iframe bridge. A desktop shell may have a native transport. A mobile webview may need special headers or tracing. Some environments have stricter CORS behavior. Some need request correlation with native logs.</p>
<p>If every feature wraps <code>fetch</code> directly, there is no single place to handle this.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  feature[&quot;Product feature&quot;] --&gt; client[&quot;Platform IO client&quot;]
  client --&gt; browser[&quot;Browser fetch&quot;]
  client --&gt; embed[&quot;Iframe bridge&quot;]
  client --&gt; desktop[&quot;Desktop native bridge&quot;]
  client --&gt; mobile[&quot;Webview bridge&quot;]

  client --&gt; trace[&quot;Tracing&quot;]
  client --&gt; auth[&quot;Auth context&quot;]
  client --&gt; retry[&quot;Retry / timeout policy&quot;]</pre></figure><p>The platform IO client does not need to hide every detail. It does need to centralize the defaults people otherwise forget: tracing, auth propagation, request metadata, retries, timeouts, and runtime-specific transports.</p>
<h2>Persistence Is Shared Whether You Like It Or Not</h2>
<p>Browser storage is not infinite, not uniform, and not truly feature-local.</p>
<p><code>localStorage</code>, IndexedDB, Cache Storage, cookies, and memory caches all have runtime-specific behavior. Private browsing modes can restrict storage. Webviews can behave differently from browsers. Multiple product features can starve each other if each one assumes storage is free.</p>
<p>That makes persistence something the platform has to own.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  featureA[&quot;Feature A&quot;] --&gt; store[&quot;Platform storage API&quot;]
  featureB[&quot;Feature B&quot;] --&gt; store
  featureC[&quot;Feature C&quot;] --&gt; store

  store --&gt; quota[&quot;Quota policy&quot;]
  store --&gt; eviction[&quot;Eviction policy&quot;]
  store --&gt; adapters[&quot;Storage adapters&quot;]
  adapters --&gt; local[&quot;localStorage&quot;]
  adapters --&gt; idb[&quot;IndexedDB&quot;]
  adapters --&gt; native[&quot;Native storage bridge&quot;]
  adapters --&gt; memory[&quot;Memory fallback&quot;]</pre></figure><p>The platform should provide a storage API, decide what is cacheable, monitor usage, and make fallback behavior explicit.</p>
<h2>Telemetry And Analytics Are Different</h2>
<p>Telemetry answers: is the application healthy?</p>
<p>Analytics answers: what did the user do?</p>
<p>They often share transport and metadata, but they are not the same system.</p>
<p>Telemetry needs page load metrics, interaction latency, client errors, logs, app version, browser version, route, device class, and deploy correlation.</p>
<p>Analytics needs product events, interaction hierarchy, attribution, experiment dimensions, and funnel metadata.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  ui[&quot;UI interaction&quot;] --&gt; metadata[&quot;Interaction metadata\nsurface, role, route, feature&quot;]
  metadata --&gt; analytics[&quot;Analytics event&quot;]
  metadata --&gt; telemetry[&quot;Telemetry context&quot;]
  telemetry --&gt; rum[&quot;RUM metrics&quot;]
  telemetry --&gt; logs[&quot;Client logs&quot;]
  telemetry --&gt; errors[&quot;Error reports&quot;]</pre></figure><p>A good platform layer makes the common case automatic. A button from the design system can carry semantic metadata. A route can provide page context. A logging client can attach app version and browser dimensions without every callsite remembering to do it.</p>
<p>This is also where older web platform work is still relevant. Dropbox invested heavily in real-user metrics because frontend performance work needs cohorts: browser, device class, route, deploy version, and network shape. Yahoo&#39;s instrumentation systems had a similar lesson from a different angle: user behavior is only useful when events carry enough context to explain where they came from.</p>
<p>In other words, telemetry and analytics should not be a thousand hand-written event calls. The platform should make the boring metadata automatic.</p>
<h2>Localization Is Runtime Work Too</h2>
<p>Localization is not only a translation pipeline.</p>
<p>The runtime also needs locale data, number/date formatting, pluralization rules, message loading, fallback behavior, and the ability to avoid shipping every locale to every user.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  source[&quot;Colocated messages&quot;] --&gt; extract[&quot;Extract catalogs&quot;]
  extract --&gt; package[&quot;Package translations&quot;]
  package --&gt; cdn[&quot;Serve by locale&quot;]
  runtime[&quot;Runtime locale resolver&quot;] --&gt; cdn
  runtime --&gt; polyfill[&quot;Locale polyfills&quot;]
  cdn --&gt; app[&quot;Localized UI&quot;]
  polyfill --&gt; app</pre></figure><p>The feature should own the words. The platform should own extraction, packaging, loading, and runtime guarantees.</p>
<p>Again, the interesting prior art is not the specific company implementation. It is the pattern. Large web properties do not ship one giant locale payload to everyone. They extract messages, package locale data, and load the right pieces for the user and runtime.</p>
<h2>Config Needs A Real Shape</h2>
<p>Every platform grows configuration.</p>
<p>The problem is not config existing. The problem is each feature inventing its own way to resolve it.</p>
<p>A platform config layer should define:</p>
<ul>
<li>the dimensions config can vary by</li>
<li>where config is loaded from</li>
<li>what is available at build time vs runtime</li>
<li>how defaults work</li>
<li>how missing or malformed config fails</li>
<li>how config usage is tested</li>
</ul>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  dimensions[&quot;Dimensions\nregion, locale, device, runtime, app version&quot;] --&gt; resolver[&quot;Config resolver&quot;]
  files[&quot;Feature config declarations&quot;] --&gt; resolver
  remote[&quot;Remote config service&quot;] --&gt; resolver
  defaults[&quot;Defaults&quot;] --&gt; resolver
  resolver --&gt; feature[&quot;Feature receives typed config&quot;]</pre></figure><p>Typed config matters because config bugs are otherwise discovered as runtime weirdness.</p>
<h2>Platform Adapters</h2>
<p>Once the runtime has a contract, each environment can implement it differently.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  contract[&quot;Web platform runtime contract&quot;]

  contract --&gt; browser[&quot;Browser adapter&quot;]
  contract --&gt; embed[&quot;Third-party embed adapter&quot;]
  contract --&gt; desktop[&quot;Desktop shell adapter&quot;]

  browser --&gt; bFetch[&quot;fetch&quot;]
  browser --&gt; bStore[&quot;IndexedDB / localStorage&quot;]
  browser --&gt; bLog[&quot;browser logging&quot;]

  embed --&gt; eFetch[&quot;iframe bridge / CORS-safe transport&quot;]
  embed --&gt; eStore[&quot;restricted storage&quot;]
  embed --&gt; eLog[&quot;host-aware logging&quot;]

  desktop --&gt; dFetch[&quot;native network bridge&quot;]
  desktop --&gt; dStore[&quot;native storage bridge&quot;]
  desktop --&gt; dLog[&quot;native logging bridge&quot;]</pre></figure><p>This is where the extra structure starts paying rent. Product features keep using one platform contract while the runtime chooses the right implementation for the environment.</p>
<p>It also keeps framework assumptions in check. A framework may assume the platform is a browser plus a Node server. Your product may need browser, embed, desktop, mobile webview, and worker surfaces. The platform layer is where those assumptions become explicit.</p>
<h2>Core Components Sit Above The Runtime</h2>
<p>Core components can assume the runtime exists.</p>
<p>That lets a design system provide components that are already:</p>
<ul>
<li>instrumented</li>
<li>localized</li>
<li>accessible</li>
<li>theme-aware</li>
<li>compatible with platform navigation</li>
<li>connected to platform config where needed</li>
</ul>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  runtime[&quot;Web platform runtime&quot;] --&gt; design[&quot;Design system&quot;]
  runtime --&gt; router[&quot;Router&quot;]
  runtime --&gt; ui[&quot;Shared UI components&quot;]
  design --&gt; feature[&quot;Product feature&quot;]
  router --&gt; feature
  ui --&gt; feature</pre></figure><p>This is better than asking every feature to remember how to tag every button, load every translation, or log every error.</p>
<h2>A Practical Starting Point</h2>
<p>Do not start by building a grand platform.</p>
<p>Start with the things every feature already reimplements:</p>
<ol>
<li>logger</li>
<li>analytics client</li>
<li>IO client</li>
<li>localization runtime</li>
<li>polyfill policy</li>
<li>feature flag or config client</li>
<li>storage API</li>
<li>design system integration</li>
</ol>
<p>Each one should have a small contract, a browser implementation, and a story for at least one non-browser environment if that is on the roadmap.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  start[&quot;Repeated feature code&quot;] --&gt; choose[&quot;Pick one platform capability&quot;]
  choose --&gt; contract[&quot;Define a small contract&quot;]
  contract --&gt; browser[&quot;Implement browser adapter&quot;]
  browser --&gt; migrate[&quot;Migrate one feature&quot;]
  migrate --&gt; enforce[&quot;Add lint/import guardrails&quot;]
  enforce --&gt; next[&quot;Move to the next capability&quot;]</pre></figure><p>The goal is not to centralize everything. Centralize the parts where inconsistency is expensive.</p>
<h2>The Rule</h2>
<p>If a product feature has to know which runtime it is running in, that is a smell.</p>
<p>Sometimes it is unavoidable. But most of the time, the feature should know what capability it needs, not which bridge, polyfill, storage backend, telemetry client, or config source provides it.</p>
<p>That is what a cross-platform web runtime buys you: not a bigger abstraction for its own sake, but a place to put the decisions that should not be rediscovered by every feature team.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>RSC Server Functions Are Not An API Boundary</title>
      <link>https://longlho.github.io/posts/rsc-server-functions-are-not-an-api-boundary/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/rsc-server-functions-are-not-an-api-boundary/</guid>
      <description>React Server Components changed the shape of full-stack React apps.</description>
      <content:encoded>
        <![CDATA[<p>React Server Components changed the shape of full-stack React apps.</p>
<p>Once the server is part of the component model, frameworks can make server work feel like ordinary application code. You render on the server. You stream UI. You pass server references through the tree. Some frameworks also let client interactions call server functions without writing a route by hand.</p>
<p>That is a good ergonomic move. It removes a lot of boilerplate around mutations that are tightly coupled to one screen.</p>
<p>The catch is that the wiring is still an API. It is just an API generated by the framework.</p>
<p>That starts to matter when an app is deployed continuously, cached by browsers and CDNs, or served by more than one backend version at the same time.</p>
<p>Next.js Server Actions are the concrete example I know best, so I will use them here. But the deeper point is not specific to one framework. If a client can invoke server code through a generated server reference, that reference has API-like failure modes.</p>
<h2>The Normal Shape</h2>
<p>Without a generated server function transport, a client mutation usually has an explicit endpoint:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  ui[&quot;Client UI&quot;] --&gt; route[&quot;POST /api/update-item&quot;]
  route --&gt; auth[&quot;Auth / authz&quot;]
  auth --&gt; schema[&quot;Input validation&quot;]
  schema --&gt; service[&quot;Service call&quot;]
  service --&gt; result[&quot;JSON response&quot;]</pre></figure><p>That endpoint is visible. You can give it a stable path, a schema, rate limits, logs, dashboards, deploy compatibility rules, and migration plans.</p>
<p>With an RSC-style server function, most of that ceremony can disappear. In Next.js, that looks like a Server Action:</p>
<pre class="code-block"><code class="language-tsx"><span class="syntax-comment">// app/actions.ts</span>
<span class="syntax-string">&#39;use server&#39;</span>

<span class="syntax-keyword">export</span> <span class="syntax-keyword">async</span> <span class="syntax-keyword">function</span> updateItem<span class="syntax-punctuation">(</span>id<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">,</span> value<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">)</span> <span class="syntax-punctuation">{</span>
  <span class="syntax-comment">// auth, validation, mutation</span>
<span class="syntax-punctuation">}</span></code></pre><pre class="code-block"><code class="language-tsx"><span class="syntax-comment">// app/item-form.tsx</span>
<span class="syntax-string">&#39;use client&#39;</span>

<span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span> updateItem <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&#39;./actions&#39;</span>

<span class="syntax-keyword">export</span> <span class="syntax-keyword">function</span> ItemForm<span class="syntax-punctuation">()</span> <span class="syntax-punctuation">{</span>
  <span class="syntax-keyword">return</span> <span class="syntax-punctuation">&lt;</span>button onClick<span class="syntax-punctuation">={()</span> <span class="syntax-punctuation">=&gt;</span> updateItem<span class="syntax-punctuation">(</span><span class="syntax-string">&#39;item_123&#39;</span><span class="syntax-punctuation">,</span> <span class="syntax-string">&#39;new value&#39;</span><span class="syntax-punctuation">)}&gt;</span>Save<span class="syntax-punctuation">&lt;/</span>button<span class="syntax-punctuation">&gt;</span>
<span class="syntax-punctuation">}</span></code></pre><p>That is nice. It also makes it easy to forget that the browser still has to call the server somehow.</p>
<p>The <a href="https://nextjs.org/docs/app/getting-started/mutating-data">Next docs are explicit</a>: Server Functions are reachable through direct <code>POST</code> requests, so authentication and authorization must happen inside every Server Function. The <code>&quot;use server&quot;</code> directive does not make a function private to your component tree.</p>
<h2>What Gets Generated</h2>
<p>The exact implementation is a framework detail, but the rough shape of a generated server function transport looks like this:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  source[&quot;Source file\nserver function export&quot;] --&gt; transform[&quot;Framework compiler transform&quot;]
  transform --&gt; id[&quot;Generated server reference ID&quot;]
  id --&gt; manifest[&quot;Server reference manifest&quot;]
  id --&gt; clientStub[&quot;Client reference\ncallServer(referenceId)&quot;]
  clientStub --&gt; request[&quot;POST request\nreference ID + serialized args&quot;]
  request --&gt; handler[&quot;Server function handler&quot;]
  manifest --&gt; handler
  handler --&gt; fn[&quot;Original server function&quot;]
  fn --&gt; response[&quot;RSC payload / redirect / revalidation / result&quot;]</pre></figure><p>A few details are worth calling out:</p>
<ul>
<li>the client does not ship the original server function</li>
<li>the client ships a reference to a generated server ID</li>
<li>the browser invokes that ID with a request</li>
<li>the server uses a manifest to route the ID back to the original function</li>
<li>the function arguments and return value must fit the serialization rules</li>
</ul>
<p>This is why server functions can feel like magic. The framework created a small RPC layer for you.</p>
<p>It is also why they need to be treated with the same care as any other RPC layer.</p>
<h2>The Generated ID Is Not A Contract</h2>
<p>The generated server reference ID is not a stable API name like <code>/api/update-item</code>.</p>
<p>At build time, the framework needs to decide which server function an ID points to. The inputs to that ID may include implementation details such as a build salt, file path, export name, bundler metadata, and function-shape information.</p>
<p>That means a small code or framework change can produce a different ID for the same conceptual mutation.</p>
<p>Next.js is a useful concrete example. In one version of its Server Action scheme, the action ID was effectively tied to a small set of build-time facts:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  salt[&quot;Build salt&quot;] --&gt; hash[&quot;SHA-1&quot;]
  file[&quot;File path&quot;] --&gt; hash
  export[&quot;Export name&quot;] --&gt; hash
  hash --&gt; id[&quot;Action ID&quot;]</pre></figure><p>In that world, this kind of refactor could keep the same action ID:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-string">&#39;use server&#39;</span>

<span class="syntax-keyword">export</span> <span class="syntax-keyword">async</span> <span class="syntax-keyword">function</span> updateItem<span class="syntax-punctuation">(</span>id<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">,</span> value<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">)</span> <span class="syntax-punctuation">{</span>
  <span class="syntax-comment">// ...</span>
<span class="syntax-punctuation">}</span></code></pre><pre class="code-block"><code class="language-ts"><span class="syntax-string">&#39;use server&#39;</span>

<span class="syntax-keyword">export</span> <span class="syntax-keyword">async</span> <span class="syntax-keyword">function</span> updateItem<span class="syntax-punctuation">(</span>
  id<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">,</span>
  value<span class="syntax-punctuation">:</span> <span class="syntax-keyword">string</span><span class="syntax-punctuation">,</span>
  options<span class="syntax-punctuation">?:</span> <span class="syntax-punctuation">{</span> optimistic<span class="syntax-punctuation">?:</span> <span class="syntax-keyword">boolean</span> <span class="syntax-punctuation">},</span>
<span class="syntax-punctuation">)</span> <span class="syntax-punctuation">{</span>
  <span class="syntax-comment">// ...</span>
<span class="syntax-punctuation">}</span></code></pre><p>The file stayed the same. The export stayed the same. If the build salt stayed the same, the action ID stayed the same.</p>
<p>Newer implementations include more function-shape information. In the current Next source, the generated server reference ID still starts from a SHA-1 over the hash salt, file name, and export name, but it also appends a byte that encodes whether the reference is a cache reference plus an argument mask and rest-argument bit.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  salt[&quot;Build salt&quot;] --&gt; hash[&quot;SHA-1 base&quot;]
  file[&quot;File path&quot;] --&gt; hash
  export[&quot;Export name&quot;] --&gt; hash
  hash --&gt; byte[&quot;Append metadata byte&quot;]
  isCache[&quot;Server action vs cache reference&quot;] --&gt; byte
  args[&quot;Argument mask&quot;] --&gt; byte
  rest[&quot;Rest argument bit&quot;] --&gt; byte
  byte --&gt; rotate[&quot;Rotate bytes&quot;]
  rotate --&gt; id[&quot;Action ID&quot;]</pre></figure><p>That is a reasonable implementation change. The framework is trying to encode more about the server reference into the ID. But operationally, it changes the compatibility story. A parameter-shape change can now mean &quot;new generated API endpoint,&quot; even if the source file and export name did not move.</p>
<p>The difference looks roughly like this:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  subgraph old[&quot;Older action ID shape&quot;]
    oldSalt[&quot;hash salt&quot;] --&gt; oldHash[&quot;hash&quot;]
    oldFile[&quot;file path&quot;] --&gt; oldHash
    oldExport[&quot;export name&quot;] --&gt; oldHash
    oldHash --&gt; oldId[&quot;same ID if those stay stable&quot;]
  end

  subgraph new[&quot;Newer action ID shape&quot;]
    newSalt[&quot;hash salt&quot;] --&gt; newHash[&quot;hash + metadata&quot;]
    newFile[&quot;file path&quot;] --&gt; newHash
    newExport[&quot;export name&quot;] --&gt; newHash
    newCache[&quot;cache/action bit&quot;] --&gt; newHash
    newArgs[&quot;argument mask&quot;] --&gt; newHash
    newRest[&quot;rest-argument bit&quot;] --&gt; newHash
    newHash --&gt; newId[&quot;new ID when shape changes&quot;]
  end</pre></figure><p>This is the kind of detail that disappears inside an upgrade. The source diff looks like a compiler implementation detail. In production, it can change the public token old clients use to call the server.</p>
<p>Another way to say it: the public &quot;API name&quot; is synthesized from code shape.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  salt[&quot;Build hash salt&quot;] --&gt; makeId[&quot;Generate server reference ID&quot;]
  file[&quot;File name\napp/actions.ts&quot;] --&gt; makeId
  fn[&quot;Export / function name\nupdateItem&quot;] --&gt; makeId
  params[&quot;Parameter shape\nid, value, options?&quot;] --&gt; makeId
  makeId --&gt; actionId[&quot;Action ID\naction_def456&quot;]
  actionId --&gt; client[&quot;Client bundle\nserver reference action_def456&quot;]
  actionId --&gt; server[&quot;Server manifest\naction_def456 -&gt; updateItem&quot;]
  client --&gt; post[&quot;POST request\naction_def456&quot;]
  post --&gt; server</pre></figure><p>That is the surprising part. The thing behaving like an API route is not named by a URL you wrote. It is named by a compiler-generated ID derived from implementation details.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  subgraph oldBuild[&quot;Build A&quot;]
    oldFn[&quot;updateItem(id, value)&quot;] --&gt; oldId[&quot;action_abc123&quot;]
  end

  subgraph newBuild[&quot;Build B&quot;]
    newFn[&quot;updateItem(id, value, options?)&quot;] --&gt; newId[&quot;action_def456&quot;]
  end

  oldId -. &quot;not guaranteed to exist&quot; .-&gt; newId</pre></figure><p>Most of the time this is fine. A fresh page load gets the fresh JavaScript bundle, which contains fresh server reference IDs, and it talks to a matching server.</p>
<p>The trouble starts during deploys.</p>
<h2>The Rolling Deploy Problem</h2>
<p>Imagine a user has an old page open. The page was rendered by Build A, and the JavaScript bundle contains <code>action_abc123</code>.</p>
<p>While the tab is open, Build B rolls out. Build B only knows about <code>action_def456</code>.</p>
<p>Then the user clicks Save.</p>
<figure class="mermaid-diagram"><pre class="mermaid">sequenceDiagram
  participant Browser as Browser with Build A JS
  participant CDN as CDN / asset cache
  participant Server as Server running Build B

  Browser-&gt;&gt;Browser: User clicks Save
  Browser-&gt;&gt;Server: POST request with action_abc123
  Server-&gt;&gt;Server: Look up action_abc123 in Build B manifest
  Server--&gt;&gt;Browser: Action not found / request fails</pre></figure><p>From the user&#39;s point of view, nothing unusual happened. They opened a page and clicked a button.</p>
<p>From the server&#39;s point of view, a client called a server reference ID that no longer exists.</p>
<p>That kind of failure is easy to miss locally. Local development usually has one browser, one server, one version, and no real deploy overlap. Production has cached assets, long-lived tabs, rolling deploys, multiple instances, retries, and users who click buttons at inconvenient times.</p>
<p>When this fails during a deploy, the graph does not look like a slow burn. It looks like a cliff:</p>
<figure class="mermaid-diagram"><pre class="mermaid">xychart-beta
  title &quot;Generated server function failures during a version-skew deploy&quot;
  x-axis [&quot;18:00&quot;, &quot;18:10&quot;, &quot;18:20&quot;, &quot;18:30&quot;, &quot;18:40&quot;, &quot;18:50&quot;, &quot;19:00&quot;, &quot;19:10&quot;]
  y-axis &quot;failed calls&quot; 0 --&gt; 120
  bar [5, 4, 6, 5, 7, 18, 105, 64]</pre></figure><p>The shape is the clue. A normal app bug usually follows traffic. Server reference skew shows up when the deploy crosses the old-client/new-server boundary.</p>
<h2>Why This Is Different From A Route Handler</h2>
<p>An explicit route can be versioned:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  oldClient[&quot;Old client&quot;] --&gt; v1[&quot;POST /api/v1/update-item&quot;]
  newClient[&quot;New client&quot;] --&gt; v2[&quot;POST /api/v2/update-item&quot;]
  v1 --&gt; shared[&quot;Shared service logic&quot;]
  v2 --&gt; shared</pre></figure><p>You can keep <code>v1</code> alive during migration. You can log usage. You can reject bad input with a typed error. You can publish a compatibility window. You can put the path in a runbook.</p>
<p>With generated server functions, the API surface is intentionally hidden. That is the point. You get less boilerplate because you gave the framework control over the transport.</p>
<p>That tradeoff is fine for many UI-local mutations. It is much less comfortable for anything that needs a durable API boundary.</p>
<h2>Use Them, But Draw The Line</h2>
<p>I would use RSC server functions or framework actions for:</p>
<ul>
<li>forms that are tightly coupled to one page</li>
<li>low-risk UI mutations</li>
<li>mutations where a full page refresh fallback is acceptable</li>
<li>actions that only need to work within one deployed version</li>
<li>code where colocating mutation logic with the route is worth the coupling</li>
</ul>
<p>I would be careful using them for:</p>
<ul>
<li>mutations called from many routes or packages</li>
<li>workflows that need schema versioning</li>
<li>endpoints used by mobile apps, extensions, workers, or other services</li>
<li>high-volume mutations that need independent rate limits and observability</li>
<li>flows that must survive rolling deploys without old-client failures</li>
<li>anything that already looks like a product API</li>
</ul>
<p>The line is not &quot;server functions are bad.&quot; The line is &quot;server functions are not a replacement for every API.&quot;</p>
<h2>Deployment Safety</h2>
<p>If you use generated server functions heavily, treat deploy compatibility as part of the design.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  action[&quot;Does this mutation need to survive old clients?&quot;] --&gt;|No| serverFunction[&quot;Generated server function is probably fine&quot;]
  action --&gt;|Yes| boundary[&quot;Use an explicit route or compatibility layer&quot;]
  boundary --&gt; version[&quot;Version the request shape&quot;]
  version --&gt; observe[&quot;Log and monitor old usage&quot;]
  observe --&gt; remove[&quot;Remove after the compatibility window&quot;]</pre></figure><p>Practical mitigations include:</p>
<ul>
<li>keep old servers around long enough for old pages to age out</li>
<li>avoid serving old HTML with new-only server manifests</li>
<li>use immutable asset URLs so a page and its JavaScript agree</li>
<li>make generated server function errors visible in monitoring</li>
<li>design important mutations so retrying through an explicit endpoint is possible</li>
<li>avoid changing function signatures casually during framework upgrades</li>
</ul>
<p>Next also has a separate concern around encrypted closed-over variables. In self-hosted multi-server deployments, the <a href="https://nextjs.org/docs/15/app/guides/data-security">official data security guide</a> calls out <code>NEXT_SERVER_ACTIONS_ENCRYPTION_KEY</code> for making encryption behavior consistent across instances. That is not the same thing as stable action IDs, but it comes from the same general place: Server Actions have build-time and deployment-time state. Treat that state deliberately.</p>
<h2>The Mental Model</h2>
<p>The easiest mistake is to think <code>&quot;use server&quot;</code> means &quot;this is just a function call.&quot;</p>
<p>It is not. It is a framework-managed network call.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  function[&quot;Looks like a function call&quot;] --&gt; rpc[&quot;Actually a generated RPC&quot;]
  rpc --&gt; api[&quot;Has API-like failure modes&quot;]
  api --&gt; deploy[&quot;Needs deploy compatibility&quot;]
  api --&gt; security[&quot;Needs auth and validation&quot;]
  api --&gt; observability[&quot;Needs logs and monitoring&quot;]</pre></figure><p>That does not make server functions useless. It makes them a sharp tool.</p>
<p>Use them where the ergonomics are worth the coupling. Reach for explicit Route Handlers, API routes, or a BFF-style API when the boundary needs to be stable, observable, versioned, or shared outside one page.</p>
<p>The practical rule I use is:</p>
<blockquote>
<p>If breaking the generated server reference would look like an API outage, give it a real API.</p>
</blockquote>
<h2>References</h2>
<ul>
<li><a href="https://react.dev/reference/rsc/server-components">React Server Components</a></li>
<li><a href="https://react.dev/reference/rsc/server-functions">React Server Functions</a></li>
<li><a href="https://nextjs.org/docs/app/api-reference/directives/use-server">Next.js <code>use server</code> directive</a></li>
<li><a href="https://nextjs.org/docs/app/getting-started/mutating-data">Next.js mutating data with Server Functions</a></li>
<li><a href="https://nextjs.org/docs/15/app/guides/data-security">Next.js data security guide</a></li>
<li><a href="https://github.com/vercel/next.js/blob/canary/crates/next-custom-transforms/src/transforms/server_actions.rs">Next.js server action transform source</a></li>
<li><a href="https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-flight-loader/action-client-wrapper.ts">Next.js action client wrapper source</a></li>
</ul>
]]>
      </content:encoded>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 1: Why Bazel For Large Frontend Monorepos?</title>
      <link>https://longlho.github.io/posts/part-1-why-bazel-for-large-frontend-monorepos/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-1-why-bazel-for-large-frontend-monorepos/</guid>
      <description>Frontend build systems usually start as a few package scripts:</description>
      <content:encoded>
        <![CDATA[<p>Frontend build systems usually start as a few package scripts:</p>
<pre class="code-block"><code class="language-bash"><span class="syntax-keyword">pnpm</span> dev
<span class="syntax-keyword">pnpm</span> test
<span class="syntax-keyword">pnpm</span> <span class="syntax-keyword">build</span></code></pre><p>That is enough when there is one app, a few shared packages, and a small team. It stops being enough when the frontend becomes a graph: apps, SDK bundles, generated clients, translations, icons, tests, Storybook, browser extensions, edge workers, server bundles, static assets, and deployable images.</p>
<p>At that point, the question changes. It is no longer only:</p>
<blockquote>
<p>How do we build the app?</p>
</blockquote>
<p>It becomes something more annoying:</p>
<blockquote>
<p>Which artifacts are affected by this change?</p>
</blockquote>
<p>That is the point where Bazel starts to make sense.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  change[&quot;Change&quot;] --&gt; pkg[&quot;Shared package&quot;]
  pkg --&gt; app[&quot;App bundle&quot;]
  pkg --&gt; test[&quot;Affected tests&quot;]
  pkg --&gt; meta[&quot;Translations / icons&quot;]
  gen[&quot;Generated clients&quot;] --&gt; pkg
  config[&quot;Typed config&quot;] --&gt; app
  meta --&gt; app
  app --&gt; verify[&quot;Output checks&quot;]
  verify --&gt; deploy[&quot;Deployable artifact&quot;]</pre></figure><h2>Package Scripts Hide The Graph</h2>
<p>A package script is easy to read:</p>
<pre class="code-block"><code class="language-json"><span class="syntax-punctuation">{</span>
  <span class="syntax-string">&quot;scripts&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-punctuation">{</span>
    <span class="syntax-string">&quot;build&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;vite build&quot;</span><span class="syntax-punctuation">,</span>
    <span class="syntax-string">&quot;test&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;vitest run&quot;</span><span class="syntax-punctuation">,</span>
    <span class="syntax-string">&quot;typecheck&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-string">&quot;tsc --noEmit&quot;</span>
  <span class="syntax-punctuation">}</span>
<span class="syntax-punctuation">}</span></code></pre><p>The script is readable, but it hides almost everything the build actually cares about. It does not say which files are inputs, which packages are dependencies, which generated clients are required, which assets must be copied, or which downstream artifacts should be invalidated.</p>
<p>Task runners can improve this a lot. Turborepo and Nx both add structure, caching, and affected workflows. For many repositories, that is the right level of abstraction.</p>
<p>Bazel becomes interesting when the frontend graph needs more than package-level tasks. For example:</p>
<ul>
<li>generated REST, protobuf, or GraphQL TypeScript packages</li>
<li>transitive translation extraction from colocated UI messages</li>
<li>per-app icon sprite generation from reachable components</li>
<li>route metadata aggregation across packages to detect collisions</li>
<li>typed config files as build inputs</li>
<li>test-only dependencies separated from runtime dependencies</li>
<li>strict boundaries between app code, shared packages, examples, tests, and tools</li>
<li>post-build verification over emitted bundles, including size budgets, environment replacement, sourcemap paths, and forbidden strings</li>
<li>deployment targets that consume built artifacts instead of rediscovering files from the working directory</li>
<li>selective side effects, such as uploading assets only when the artifact that feeds the upload changed</li>
</ul>
<p>Those are not just commands. They are files, manifests, packages, checks, and uploads with dependencies.</p>
<p>Once you look at them that way, the system feels different. A translation catalog is not a random file that appears before build. It is an app-specific artifact derived from the dependency graph. A generated client is not just a prebuild step. It is a package with consumers. A CDN upload is not a shell command at the end of CI. It is a side effect attached to a verified bundle.</p>
<h2>Selective Builds Need Honest Boundaries</h2>
<p>Selective builds are only useful if you can trust them.</p>
<p>If a package can import undeclared dependencies, read undeclared files, or rely on global workspace state, the build graph becomes a guess. It may be fast, but it is not trustworthy.</p>
<p>Bazel pushes teams toward explicit boundaries:</p>
<ul>
<li>source files are declared</li>
<li>assets are declared</li>
<li>dependencies are declared</li>
<li>generated artifacts have labels</li>
<li>config files are inputs</li>
<li>tests have their own dependency surface</li>
<li>deployment artifacts consume known outputs</li>
</ul>
<p>There is a cost. BUILD files, macros, generators, and rules become real work. But the payoff is simple: a change to one package does not have to become a global frontend event.</p>
<h2>Where The Pain Shows Up</h2>
<p>The pain usually does not appear on day one. It appears after the repository has a few generations of frontend architecture in it.</p>
<p>One team adds a shared component package. Another adds a browser extension. Another adds generated API clients. A platform team adds translation extraction. A design-system team adds icon sprites. A product team adds a second app. CI grows a pile of scripts that run &quot;just to be safe.&quot;</p>
<p>Eventually, simple questions get expensive:</p>
<ul>
<li>Did this change affect the app shell?</li>
<li>Do we need to rebuild every SDK?</li>
<li>Which tests cover this shared package?</li>
<li>Why did this bundle include that dependency?</li>
<li>Which generated client version is being typechecked?</li>
<li>Why did CI fail when local development worked?</li>
</ul>
<p>Those questions get expensive because the graph already exists, but it lives in scripts, conventions, and memory. Bazel&#39;s value is making enough of that graph explicit that tools and humans can reason about it.</p>
<h2>The Real Goal</h2>
<p>I do not think the goal is to make every frontend engineer a Bazel expert.</p>
<p>The common path should be boring:</p>
<ul>
<li>create a package</li>
<li>split a package</li>
<li>add a dependency</li>
<li>add a test</li>
<li>add a generated client</li>
<li>build one app</li>
<li>run affected tests</li>
<li>understand CI failures</li>
</ul>
<p>Good Bazel rules hide most of the machinery while preserving the graph. The rest of this series is about that balance: package anatomy, generated BUILD files, typechecking contracts, aspects, generated clients, Vite bundles, tests, output verification, deployment targets, dependency hygiene, and how Bazel compares with Turborepo and Nx.</p>
<p>Bazel is not the easiest frontend build tool to adopt. It asks the repository to be precise.</p>
<p>In a large monorepo, that precision is the feature.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 2: The Anatomy Of A Frontend Package</title>
      <link>https://longlho.github.io/posts/part-2-the-anatomy-of-a-frontend-package/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-2-the-anatomy-of-a-frontend-package/</guid>
      <description>A frontend package is rarely just a folder of TypeScript files.</description>
      <content:encoded>
        <![CDATA[<p>A frontend package is rarely just a folder of TypeScript files.</p>
<p>In a large monorepo, the same directory may contain runtime source, tests, stories, config files, fixtures, JSON, CSS, generated files, and static assets. Treating all of that as one project makes dependency boundaries fuzzy.</p>
<p>Bazel works better when a package is modeled as a small set of artifacts.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  src[&quot;Runtime source&quot;] --&gt; lib[&quot;Library target&quot;]
  assets[&quot;Runtime assets&quot;] --&gt; lib
  tests[&quot;Tests&quot;] --&gt; test[&quot;Test target&quot;]
  lib --&gt; test
  stories[&quot;Stories&quot;] --&gt; storybook[&quot;Storybook target&quot;]
  lib --&gt; storybook
  config[&quot;Typed config&quot;] --&gt; app[&quot;Leaf app / SDK&quot;]
  lib --&gt; app
  lib --&gt; meta[&quot;Extracted metadata&quot;]</pre></figure><h2>Internal Packages Vs Leaf Packages</h2>
<p>Internal packages are the pieces other code builds on. They expose components, hooks, clients, utilities, styles, or framework helpers.</p>
<p>Leaf packages produce something runnable or deployable: an app, SDK bundle, browser extension entry, worker script, example, or server artifact.</p>
<p>Those two shapes should not be modeled the same way. A shared library should not carry app deployment concerns. An app bundle should not pretend it is just another library.</p>
<h2>Why Not A New <code>package.json</code> Every Time?</h2>
<p>In JavaScript workspaces, the natural instinct is to make every boundary a new package with its own <code>package.json</code>.</p>
<p>That can work, but it gets clunky at monorepo scale. Package names are global within the workspace, so every small boundary needs a unique, stable, bikeshed-prone name. Renaming a package becomes more expensive than moving a directory. Publishing-oriented metadata starts leaking into internal architecture. A tiny refactor can turn into package naming, exports, dependency metadata, and toolchain ceremony.</p>
<p>Bazel gives another option: a package boundary can be a BUILD package, not necessarily an npm package. The directory and target label can carry the internal boundary while the root package manager still owns installed third-party versions.</p>
<p>This is especially useful for package splitting. Moving code from <code>features/search/results</code> to <code>features/search/result-card</code> should not require inventing a public package name if the boundary is only internal to the repository.</p>
<h2>Split The Surfaces</h2>
<p>A frontend package usually has several surfaces:</p>
<ul>
<li><strong>runtime source</strong>: code that ships or is imported by other packages</li>
<li><strong>tests</strong>: test files, fixtures, test-only libraries</li>
<li><strong>stories/examples</strong>: documentation and demo surfaces</li>
<li><strong>config</strong>: Vite, Vitest, Storybook, Tailwind, codegen, or build tooling</li>
<li><strong>assets</strong>: JSON, CSS, SVG, images, translations, static files</li>
<li><strong>generated outputs</strong>: clients, manifests, locale metadata, sprites</li>
</ul>
<p>The important rule: dependencies should attach to the surface that uses them.</p>
<p>A test runner belongs to the test target. A Vite plugin belongs to the config target. A browser-only library belongs to browser runtime source. A generated client belongs to the package that imports it.</p>
<p>This keeps production dependencies cleaner and makes selective builds less hand-wavy.</p>
<h2>Companion Targets</h2>
<p>One package may expand into several predictable targets:</p>
<ul>
<li>library</li>
<li>typecheck</li>
<li>runtime source sidecar</li>
<li>unit tests</li>
<li>Storybook build</li>
<li>visual tests</li>
<li>extracted translations</li>
<li>extracted icon usage</li>
<li>internal dependency metadata</li>
</ul>
<p>Developers should not handwrite all of this every time. A macro or generator can create the standard targets from conventions. The useful part is the predictable shape: downstream rules can rely on stable names and known behavior.</p>
<h2>Why This Shape Helps</h2>
<p>This structure pays off when code moves, which is the part people tend to underestimate.</p>
<p>If a test file changes, the test target should be invalidated. The production bundle usually should not be. If a Storybook story changes, the Storybook target should rebuild. Runtime consumers should not inherit Storybook dependencies. If a generated locale file changes, app bundles that consume it should rebuild, but unrelated packages should stay out of the blast radius.</p>
<p>That is the practical reason to split package surfaces. It is not just tidier modeling. It gives the build system enough information to skip work without guessing.</p>
<p>It also gives better errors. &quot;The config target is missing a dependency&quot; is much more useful than &quot;the package failed.&quot; &quot;This test imports an undeclared fixture&quot; is better than a mysterious CI-only file-not-found error.</p>
<h2>Assets Are Inputs</h2>
<p>Frontend packages are rarely pure TypeScript.</p>
<p>If source imports JSON, the JSON is an input. If a component imports CSS, the CSS is an input. If tests need fixtures, fixtures are inputs. If an app embeds translations or sprites, those generated files are inputs.</p>
<p>Sandboxed builds are blunt here, in a useful way. If an action reads a file that was not declared, the build should fail. That failure means the graph is missing an edge.</p>
<h2>Visibility Is Architecture</h2>
<p>Package visibility can feel bureaucratic until it catches the first bad dependency. Then it starts to look like architecture encoded in the build graph.</p>
<p>A design-system package may expose supported components and hide internals. An app feature may be visible only within that app. A generated client may be broadly visible. A test helper may be visible only to tests.</p>
<p>Without visibility, every package can depend on every other package. That is how internal implementation details become public APIs by accident.</p>
<p>There is also a useful convention from Go: directories named <code>internal</code> can only be imported by code within the parent tree. Frontend monorepos can copy that idea even if the language does not enforce it natively.</p>
<p>For example, <code>features/search/internal/ranking</code> can be treated as visible only to <code>features/search/...</code>. A lint rule or import-boundary check can enforce the convention. Bazel visibility can enforce it at the build graph layer. Together, they let packages have private implementation subtrees without turning every helper into a globally importable module.</p>
<h2>The Package Checklist</h2>
<p>A good package answers these questions clearly:</p>
<ul>
<li>What code ships?</li>
<li>What code tests it?</li>
<li>What config builds it?</li>
<li>What assets does it need?</li>
<li>What generated artifacts does it consume?</li>
<li>What metadata does it expose?</li>
<li>Who may depend on it?</li>
</ul>
<p>Bazel does not answer those questions automatically. The build rules have to encode them. Once they do, a package boundary becomes more than a folder convention. It becomes a useful interface to the frontend graph.</p>
<h2>Where Codegen Fits</h2>
<p>The best BUILD file is often the one most engineers do not have to edit.</p>
<p>Large frontend repositories change constantly: files move, packages split, tests appear, generated clients change, and imports drift. If every one of those changes requires careful BUILD-file surgery, the build system becomes a tax on refactoring.</p>
<p>Generation is how you keep Bazel precise without making it tedious.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  files[&quot;Source files&quot;] --&gt; scan[&quot;Import / file scanner&quot;]
  imports[&quot;Imports&quot;] --&gt; scan
  scan --&gt; build[&quot;Generated BUILD metadata&quot;]
  build --&gt; lib[&quot;Library target&quot;]
  build --&gt; config[&quot;Config target&quot;]
  build --&gt; test[&quot;Test target&quot;]
  human[&quot;Human decisions&quot;] --&gt; build</pre></figure><p>Codegen is good at mechanical facts: source files, test files, story files, config files, static assets, import paths, npm packages, internal deps, and generated-package deps.</p>
<p>Humans should still own architectural choices: visibility, package boundaries, deployability, unusual runtime assets, intentional ambient deps, and special bundling behavior.</p>
<p>The generator emits the common shape. The rules own the behavior.</p>
<p>One promising tool in this space is <a href="https://github.com/hermeticbuild/gazelle_ts"><code>hermeticbuild/gazelle_ts</code></a>, a Gazelle TypeScript language extension that generates abstract TypeScript rule kinds and lets consumers map those kinds to project-specific macros. The important idea is the abstraction boundary: the generator can own source/test/config/import discovery while the repository still owns the concrete rule behavior.</p>
<h2>Stable Target Names Are APIs</h2>
<p>Generated targets should be predictable. For a package named <code>ui</code>, a repository might consistently create <code>:ui</code>, <code>:ui_typecheck</code>, <code>:ui_srcs</code>, <code>:vitest_test</code>, <code>:extracted_translations</code>, and <code>:extracted_sprite_icons</code>.</p>
<p>The names are less important than the stability. Other rules can compose those targets without knowing the package internals.</p>
<p>Stable target names are build-system APIs. Treat them that way.</p>
<h2>Package Splitting Should Be Boring</h2>
<p>Package splitting is a great test of build ergonomics.</p>
<p>The desired workflow is: move files, update imports, run the generator, and run affected tests.</p>
<p>If splitting requires hand-editing several build targets, copy-pasting config, and guessing dependency lists, engineers will avoid it. The repository will accumulate oversized packages because the better shape is too expensive.</p>
<p>Bazel provides the precise graph. Codegen makes that graph affordable to maintain.</p>
<h2>Absolute Imports Help Refactors</h2>
<p>Generated dependency metadata works especially well with stable absolute imports.</p>
<p>Relative imports are fine inside a tiny package, but they become noisy when files move:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span> formatDate <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;../../../common/date&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>Move the file one directory deeper and the import changes even though the dependency did not.</p>
<p>Absolute subpath imports avoid that churn:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span> formatDate <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#features/common/date&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>The import describes the logical dependency rather than the file&#39;s current relative position. That is more ergonomic for refactors, and it gives tools like Gazelle a stable string to resolve into a Bazel label.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 3: Typechecking And Dependency Hygiene</title>
      <link>https://longlho.github.io/posts/part-3-typechecking-and-dependency-hygiene/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-3-typechecking-and-dependency-hygiene/</guid>
      <description>Most TypeScript projects start with one command:</description>
      <content:encoded>
        <![CDATA[<p>Most TypeScript projects start with one command:</p>
<pre class="code-block"><code class="language-bash">tsc <span class="syntax-punctuation">--</span>noEmit</code></pre><p>Large frontend monorepos usually need more than one TypeScript contract. Runtime source, tests, config files, generated clients, browser code, server code, examples, and tools all have different assumptions.</p>
<p>One giant typecheck usually becomes too loose, too slow, or both.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  src[&quot;Runtime source&quot;] --&gt; runtime[&quot;Runtime typecheck&quot;]
  tests[&quot;Test files&quot;] --&gt; testcheck[&quot;Test typecheck&quot;]
  src --&gt; testcheck
  config[&quot;Tooling config&quot;] --&gt; configcheck[&quot;Config typecheck&quot;]
  generated[&quot;Generated clients&quot;] --&gt; runtime
  deps[&quot;Declared deps&quot;] --&gt; runtime
  visibility[&quot;Visibility rules&quot;] -.enforce.-&gt; runtime</pre></figure><h2>Typecheck Is Not Transpile</h2>
<p>Typechecking asks: is this code valid?</p>
<p>Transpilation asks: what JavaScript and declarations should be emitted?</p>
<p>Bundling asks: what runtime artifact should be loaded?</p>
<p>Those are different jobs. A shared library may emit declarations. A Vite app root may typecheck but not emit JavaScript because the bundler owns output. A config file may typecheck but never become runtime code.</p>
<p>Bazel lets those jobs be separate targets.</p>
<p>This matters for performance too. Typechecking can be significantly slower than transpilation because it needs semantic analysis across files and declarations. Modern tooling reflects that split: <a href="https://oxc.rs/"><code>Oxc</code></a> focuses on a high-performance JavaScript/TypeScript toolchain, including fast parsing and transformation, while TypeScript&#39;s native Go effort, exposed through the <code>tsgo</code> entry point in the TypeScript 7 beta, exists specifically because TypeScript performance is important enough to justify a native implementation path.</p>
<p>The build graph should take advantage of that distinction. Fast transpilation or bundling can happen where JavaScript output is needed. Typechecking can run as its own target, cached independently, and scoped to the surface that actually needs that contract.</p>
<h2>Split The Contracts</h2>
<p>Runtime source should include runtime dependencies and exclude tests, stories, examples, and tools. If runtime code imports a test helper or Node-only package, the runtime target should fail.</p>
<p>Tests need their own contract. They often import assertion libraries, DOM simulators, mock servers, fixtures, and snapshot utilities. Those dependencies should not leak into runtime libraries.</p>
<p>Config files are code too. <code>vite.config.ts</code>, <code>vitest.config.ts</code>, Storybook config, Tailwind config, and codegen config can all import packages and change output artifacts. A bundler plugin belongs to the config target, not every runtime package the app imports.</p>
<p>Generated clients should be declared typecheck inputs, not ambient files that happen to exist locally. Browser code should typecheck against browser APIs. Server code should typecheck against server APIs. Worker code may need a third contract.</p>
<h2>The Dependency Side</h2>
<p>A root <code>package.json</code> says what is installed. A Bazel target&#39;s deps say what that target uses.</p>
<p>Those are different. A workspace may install hundreds of packages. A component should depend on a small, explicit subset.</p>
<p>Runtime deps belong to runtime source. Test deps belong to tests. Config deps belong to config targets. Generated deps belong to packages that import generated artifacts.</p>
<p>Dependency placement is architecture.</p>
<p>Ambient deps should be rare and reviewed. Visibility rules should catch accidental architecture. Shared packages should not import app packages. Browser-only packages should not import server-only modules. Generated packages should not depend on handwritten app code.</p>
<p>Circular dependencies should be treated as build graph bugs, not harmless TypeScript trivia. They make initialization order fragile, make tests harder to isolate, and make package splitting much harder. Bazel&#39;s explicit dependency graph gives teams a natural place to detect and reject cycles between packages.</p>
<p>This is another place where the frontend can borrow from stricter ecosystems. If a package graph has a cycle, the boundary is usually wrong: either a shared lower-level package needs to be extracted, or one package is reaching through another package&#39;s public API in the wrong direction.</p>
<h2>A Concrete Failure Mode</h2>
<p>Imagine one global typecheck that includes runtime source, tests, config files, and Node scripts.</p>
<p>To make that pass, the tsconfig often grows broad: DOM types, Node types, test globals, bundler globals, generated paths, and tool-specific settings all live together. That can hide real bugs. Browser code may accidentally use a Node API. Server code may accidentally assume the DOM. Runtime source may import a test helper. Config-only dependencies may appear available everywhere.</p>
<p>Separate typecheck targets and dependency surfaces make those mistakes visible. The stricter model is often less painful because failures are more specific.</p>
<h2>Make Failures Fixable</h2>
<p>Dependency checks should not feel like riddles.</p>
<p>A good failure says which import caused the problem, which target owns the importing file, and whether the missing dependency belongs to runtime, test, config, or generated deps. A visibility failure should explain which boundary was crossed.</p>
<p>Strictness works when the correct fix is obvious. If people have to guess, they will add broad dependencies or look for workarounds.</p>
<h2>Lint Rules Need Typecheck Too</h2>
<p>Lint rules are code, and serious frontend lint rules often depend on type information.</p>
<p>An import-boundary rule may need to understand generated import aliases. An <code>internal</code>-directory rule may need to resolve absolute subpath imports. A rule banning browser APIs in server code may need to know which files belong to which surface. A rule that detects unsafe framework usage may need TypeScript&#39;s semantic model rather than just syntax.</p>
<p>That means lint tooling has its own contract. The lint rule implementation should typecheck. Its test fixtures should typecheck where relevant. Its target should declare the same generated package mappings and type roots it needs to reason about source accurately.</p>
<p>Otherwise, lint becomes a parallel, weaker build system that guesses at the graph.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 4: Transitive Metadata And Generated Packages</title>
      <link>https://longlho.github.io/posts/part-4-transitive-metadata-and-generated-packages/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-4-transitive-metadata-and-generated-packages/</guid>
      <description>Some frontend build inputs are not local to a package, and some are not handwritten source at all.</description>
      <content:encoded>
        <![CDATA[<p>Some frontend build inputs are not local to a package, and some are not handwritten source at all.</p>
<p>Translations, icon sprites, route metadata, internal dependency reports, REST clients, protobuf declarations, and GraphQL operation packages all sit in the middle. They are source-like enough for apps to consume, but generated or aggregated enough that package scripts handle them poorly.</p>
<p>This is where Bazel&#39;s graph model is useful in a very practical way.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  app[&quot;App target&quot;] --&gt; pkg[&quot;Package graph&quot;]
  pkg --&gt; aspect[&quot;Aspect collection&quot;]
  aspect --&gt; meta[&quot;Translations / icons / routes / deps&quot;]
  schema[&quot;Schemas / operations&quot;] --&gt; gen[&quot;Generated TS packages&quot;]
  gen --&gt; pkg
  meta --&gt; app</pre></figure><h2>Aspects For Transitive Metadata</h2>
<p>A package can typecheck itself. A test can run its own files. But questions like these are different:</p>
<blockquote>
<p>Which translations, icons, routes, and internal packages are reachable from this app?</p>
</blockquote>
<p>Those are graph questions. Bazel aspects are one way to answer them without scanning the entire repository or maintaining brittle manifests by hand.</p>
<p>A normal rule says: given these inputs, produce these outputs. An aspect says: while walking this dependency graph, collect something from every target you visit.</p>
<h2>Translations And Icons</h2>
<p>Translations are a natural fit. Messages often live in shared components, feature packages, and app code. An app catalog should include strings reachable from the app, not every string in the monorepo.</p>
<p><a href="https://formatjs.github.io/"><code>FormatJS</code></a> is a good concrete example of this workflow. Its <a href="https://formatjs.github.io/docs/getting-started/application-workflow/">application workflow guide</a> describes extraction as the step that aggregates <code>defaultMessage</code>s and descriptions into JSON for translation, followed by editing or uploading/downloading translations through a translation service.</p>
<p>That model is much easier to live with when messages are colocated with the components that own them. A button owns its label. An empty state owns its copy. A feature owns its messages. Engineers do not have to update a distant central translation file every time they move UI code.</p>
<p>Bazel then gives the missing monorepo piece: extraction can follow the dependency graph. Shared packages can colocate messages locally, while an app target aggregates only the messages reachable from that app. <a href="https://registry.bazel.build/modules/rules_formatjs"><code>rules_formatjs</code></a> is a useful reference point for modeling FormatJS extraction in Bazel. The result is the best of both worlds: colocated source ergonomics and app-level translation artifacts.</p>
<p>Icon sprites work the same way. A design system may ship thousands of icons. Most apps use a small subset. A production build can statically extract icon names from source, aggregate usage through dependencies, and generate a filtered sprite.</p>
<p>The app does not need to know which shared component used which icon. The graph already has that information.</p>
<h2>Route Collision Checks</h2>
<p>Routes are another useful example because collisions are often not local.</p>
<p>One package may contribute account routes. Another may contribute settings routes. A third may contribute an embedded app or route group mounted under a shared prefix. Each package can look correct in isolation, while the final app accidentally registers two handlers for the same path.</p>
<p>An aspect can collect route metadata across the app graph and feed a collision check:</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  account[&quot;account package\n/settings&quot;] --&gt; app[&quot;app target&quot;]
  billing[&quot;billing package\n/settings/billing&quot;] --&gt; app
  embed[&quot;embedded app\n/settings&quot;] --&gt; app
  app --&gt; aspect[&quot;route metadata aspect&quot;]
  aspect --&gt; manifest[&quot;aggregated route manifest&quot;]
  manifest --&gt; check[&quot;collision check&quot;]</pre></figure><p>The check can validate more than exact duplicates. It can catch conflicting dynamic patterns like <code>/teams/:id</code> and <code>/teams/new</code>, overlapping catch-all routes, incompatible layouts mounted at the same prefix, or two apps trying to own the same browser extension route.</p>
<p>The important part is ownership. Individual packages own local route declarations. The app owns the aggregate route table. Bazel provides the graph walk that connects the two without a manually maintained central manifest.</p>
<h2>Generated Packages</h2>
<p>Generated TypeScript should be produced by labeled targets, and consumers should depend on those targets.</p>
<p>REST clients may come from OpenAPI, TypeSpec, framework route schemas, or service contracts. Protobuf-generated frontend types may include cross-package references. GraphQL operation packages may emit typed documents, result types, persisted-query hashes, and upload manifests.</p>
<p>These are not ghost dependencies. Schemas are inputs. Generated TypeScript is output. Consumers depend on generated package targets.</p>
<h2>Import Conventions</h2>
<p>Generated imports should be visually distinct and stable:</p>
<pre class="code-block"><code class="language-ts"><span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span> client <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#generated/rest/users/client.js&quot;</span><span class="syntax-punctuation">;</span>
<span class="syntax-keyword">import</span> <span class="syntax-keyword">type</span> <span class="syntax-punctuation">{</span> Message <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#generated/protobuf/messages/Message.js&quot;</span><span class="syntax-punctuation">;</span>
<span class="syntax-keyword">import</span> <span class="syntax-punctuation">{</span> SearchQuery <span class="syntax-punctuation">}</span> <span class="syntax-keyword">from</span> <span class="syntax-string">&quot;#generated/graphql/search.graphql.js&quot;</span><span class="syntax-punctuation">;</span></code></pre><p>The exact namespace does not matter. What matters is that generated imports map cleanly to generated build targets.</p>
<h2>Direct Deps Still Matter</h2>
<p>A generated import namespace does not remove the need for dependency declarations.</p>
<p>If a package imports a generated REST client, it should depend on that REST client target. If it imports generated protobuf declarations, it should depend on the protobuf target. If it imports generated GraphQL operations, it should depend on the operation package.</p>
<p>This matters for build correctness, but it also matters for review. A new generated client dependency often means the package now talks to a new service or relies on a new contract. That is architectural information.</p>
<p>The broader pattern is the same for aspects and generated packages: stop relying on side effects that happen to run first, and give the artifacts names in the graph.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 5: Apps, SDKs, And Tests In The Build Graph</title>
      <link>https://longlho.github.io/posts/part-5-apps-sdks-and-tests-in-the-build-graph/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-5-apps-sdks-and-tests-in-the-build-graph/</guid>
      <description>Bazel should not replace Vite.</description>
      <content:encoded>
        <![CDATA[<p>Bazel should not replace Vite.</p>
<p>Vite is good at bundling frontend code. Bazel is good at modeling repository-scale inputs and outputs. Large frontend monorepos need both.</p>
<p>The split I like is:</p>
<blockquote>
<p>Bazel decides what the app needs. Vite turns it into a bundle.</p>
</blockquote>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  src[&quot;App source target&quot;] --&gt; vite[&quot;Vite bundle&quot;]
  config[&quot;Typed Vite config&quot;] --&gt; vite
  gen[&quot;Generated clients&quot;] --&gt; src
  meta[&quot;Translations / sprite&quot;] --&gt; vite
  assets[&quot;Runtime assets&quot;] --&gt; vite
  vite --&gt; tests[&quot;Tests / Storybook&quot;]
  vite --&gt; verify[&quot;Output checks&quot;]</pre></figure><h2>Apps Are Leaf Nodes</h2>
<p>Internal packages are composable. Apps and SDKs are leaf nodes.</p>
<p>A leaf target produces something runnable or deployable: a web app bundle, static SDK directory, browser-extension entry, iframe embed, worker script, or server asset directory.</p>
<p>The app target should gather exactly what it needs: source, config, generated clients, assets, translations, sprites, and environment inputs.</p>
<h2>Source And Config Are Separate</h2>
<p>Runtime source depends on React, shared packages, state libraries, generated clients, and assets. Config depends on Vite, plugins, CSS tooling, and build helpers.</p>
<p>Those dependency sets should not be mixed.</p>
<p>A Vite plugin is not a runtime dependency just because it builds runtime code.</p>
<h2>App Roots May Typecheck Without Emitting</h2>
<p>Shared TypeScript libraries often emit declarations and JavaScript.</p>
<p>Vite app roots are different. The bundler owns the final JavaScript. The app source target may only need to typecheck, expose runtime files, and provide metadata for translations or sprites.</p>
<p>This avoids output collisions and keeps responsibilities clear.</p>
<h2>Tests Are Consumers</h2>
<p>Tests are not runtime source. They are consumers of runtime source.</p>
<p>A test target should depend on the package under test, plus test files and test-only dependencies. That gives a clean split: runtime typecheck can pass or fail independently, test typecheck can include test-only libraries, and production bundles do not inherit test dependencies.</p>
<p>Storybook and visual tests are also consumers. They may need browsers, fonts, screenshots, fixtures, themes, and Storybook packages. Those dependencies are heavy. They should be explicit, and they should not leak into runtime package deps.</p>
<h2>Assets, Fixtures, And Environment Inputs</h2>
<p>Vite actions and test actions need more than <code>.ts</code> files: CSS, JSON, SVG, static files, translation files, generated sprites, fixtures, snapshots, and environment-shaped values.</p>
<p>If an action reads a file, that file should be in the graph. If a client bundle needs a public environment value, that value should be modeled as a build input. If a server runtime needs an env file, that should be separate from client-side replacement.</p>
<p>A reliable app or test is not just code. It is code plus declared data.</p>
<h2>Output Shape Is Part Of The API</h2>
<p>Different Vite targets need different output shapes.</p>
<p>An app may emit an entire <code>dist</code> directory. A browser-extension background script may need a specific <code>background.js</code> file. An embedded SDK may need stable filenames. A server app may need static assets copied into another package.</p>
<p>Those output shapes are not incidental. Downstream verification, uploads, images, workers, and tests depend on them.</p>
<p>That is how &quot;run Vite&quot; becomes a reliable artifact-producing target instead of a script that happens to work on one machine.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 6: Verifying And Deploying Frontend Artifacts</title>
      <link>https://longlho.github.io/posts/part-6-verifying-and-deploying-frontend-artifacts/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-6-verifying-and-deploying-frontend-artifacts/</guid>
      <description>A successful bundle is not the same thing as a correct artifact.</description>
      <content:encoded>
        <![CDATA[<p>A successful bundle is not the same thing as a correct artifact.</p>
<p>The bundler answered one question:</p>
<blockquote>
<p>Can these inputs become output files?</p>
</blockquote>
<p>The build still needs another question:</p>
<blockquote>
<p>Are those output files acceptable?</p>
</blockquote>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  checks[&quot;Output checks&quot;] --&gt; build[&quot;Verified bundle&quot;]
  build --&gt; server[&quot;Server binary&quot;]
  server --&gt; image[&quot;OCI image&quot;]
  build --&gt; cdn[&quot;CDN upload&quot;]
  build --&gt; ext[&quot;Extension package&quot;]
  build --&gt; worker[&quot;Edge worker deploy&quot;]</pre></figure><h2>Built Artifacts Are APIs</h2>
<p>Bundles have consumers: browsers, servers, CDNs, deployment scripts, monitoring tools, extension packages, and image builders.</p>
<p>If output shape changes accidentally, those consumers can break even when the build succeeded.</p>
<p>Useful checks include file size, environment replacement, asset references, sourcemap paths, CDN paths, chunk policy, and forbidden patterns.</p>
<p>These checks should consume the built artifact, not the source tree.</p>
<h2>Concrete Built Asset Checks</h2>
<p>A large SPA usually has a bunch of assumptions that nobody wants to check by hand. Put the cheap ones against the emitted <code>dist</code> directory.</p>
<p>Useful checks include:</p>
<ul>
<li><strong>forbidden strings in HTML</strong>: restricted feature names, internal-only route names, or experiment scaffolding should not appear in public HTML.</li>
<li><strong>sensitive constants in JS</strong>: server-only identifiers, private enum values, or backend-only flags should not leak into public bundles.</li>
<li><strong>expected chunk placement</strong>: code intentionally split behind restricted or dynamic boundaries should emit under a known directory or filename pattern.</li>
<li><strong>CSS loading behavior</strong>: critical CSS should be present as a blocking stylesheet link if the app depends on it to avoid flash-of-unstyled-content.</li>
<li><strong>runtime CSS invariants</strong>: generated CSS can be scanned for required or forbidden properties when browser behavior depends on them.</li>
<li><strong>bundle budgets by pattern</strong>: vendor chunks, app-shell chunks, i18n chunks, route chunks, and CSS can each have separate size limits.</li>
</ul>
<p>That often looks like a few focused tests wired to the bundle target:</p>
<pre class="code-block"><code class="language-python">output_scan_test<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;env_var_no_leftover_test&quot;</span><span class="syntax-punctuation">,</span>
    rules <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:no_leftover_env_vars&quot;</span><span class="syntax-punctuation">],</span>
    targets <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">],</span>
<span class="syntax-punctuation">)</span>

output_scan_test<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;critical_css_loading_test&quot;</span><span class="syntax-punctuation">,</span>
    rules <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:critical_css_is_blocking&quot;</span><span class="syntax-punctuation">],</span>
    targets <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">],</span>
<span class="syntax-punctuation">)</span>

file_size_test<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;bundle_size_test&quot;</span><span class="syntax-punctuation">,</span>
    folder <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">,</span>
    pattern_size_map <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">{</span>
        <span class="syntax-string">&quot;**/vendors-*.js&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-number">425</span><span class="syntax-punctuation">,</span>
        <span class="syntax-string">&quot;**/app-shell-*.js&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-number">350</span><span class="syntax-punctuation">,</span>
        <span class="syntax-string">&quot;**/i18n-*.js&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-number">300</span><span class="syntax-punctuation">,</span>
        <span class="syntax-string">&quot;**/*.css&quot;</span><span class="syntax-punctuation">:</span> <span class="syntax-number">565</span><span class="syntax-punctuation">,</span>
    <span class="syntax-punctuation">},</span>
<span class="syntax-punctuation">)</span></code></pre><p>The exact rule names do not matter. The important choice is that the checks read the built files. They catch the things source analysis cannot: a placeholder that survived replacement, a server-only string that became reachable, a chunk that moved into the wrong public bucket, or a CSS policy that disappeared during extraction.</p>
<h2>Source Checks Are Not Enough</h2>
<p>Many output bugs do not exist in source form.</p>
<p>The source may contain <code>import.meta.env.PUBLIC_URL</code>, but the emitted bundle contains the actual string. The source may import a dynamic chunk, but the emitted manifest decides the filename. The source may configure sourcemaps, but the emitted comment determines what production debuggers see.</p>
<p>That is why output checks should inspect output. They validate the artifact the user, CDN, server, or monitoring system will actually see.</p>
<h2>Generated Assets Need Checks Too</h2>
<p>Generated runtime assets deserve the same treatment as bundles.</p>
<p>Translation catalogs can be structurally invalid. Locale files can miss keys. Icon sprites can reference symbols that do not exist in the master sprite. Route manifests can point to stale outputs. GraphQL persisted-query manifests can miss operations.</p>
<p>These checks are usually cheap compared with finding the problem after deployment. If a generated asset affects runtime behavior, it should have a verification target.</p>
<h2>Deployment Is A Consumer</h2>
<p>Deployment should not rediscover files from the working directory.</p>
<p>The cleaner path is:</p>
<ol>
<li>Build targets produce artifacts.</li>
<li>Verification targets validate artifacts.</li>
<li>Deployment targets consume artifacts.</li>
</ol>
<p>Server bundles, frontend images, CDN uploads, browser extensions, and edge workers are all consumers of the artifact graph.</p>
<h2>Upload Checks Are Artifact Checks Too</h2>
<p>Deployment checks should verify that upload targets and emitted paths agree.</p>
<p>For example:</p>
<ul>
<li><strong>public asset uploads exclude source maps</strong> so <code>.map</code> files do not get served as ordinary static assets.</li>
<li><strong>source maps upload through a separate target</strong> with the same public URL prefix the browser will use for minified files.</li>
<li><strong>restricted or private debug artifacts use a separate destination</strong> from normal public assets.</li>
<li><strong>CDN base paths appear in the emitted bundle</strong> so runtime asset resolution matches the deployment environment.</li>
<li><strong>source map URLs do not contain doubled path segments</strong> such as <code>/assets/assets/</code>.</li>
<li><strong>environment placeholders are fully replaced</strong> and no placeholder-like values remain in emitted JS.</li>
<li><strong>success markers wait for every required side effect</strong> instead of marking a deployment ready after only one upload step.</li>
<li><strong>signed packages are conditional on signing material</strong> so local and CI builds can still produce unsigned artifacts without pretending to publish them.</li>
</ul>
<p>In Bazel, these become ordinary graph edges:</p>
<pre class="code-block"><code class="language-python">upload_assets<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;upload_public_assets&quot;</span><span class="syntax-punctuation">,</span>
    src <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">,</span>
    exclude <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;*.map&quot;</span><span class="syntax-punctuation">],</span>
<span class="syntax-punctuation">)</span>

upload_sourcemaps<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;upload_sourcemaps&quot;</span><span class="syntax-punctuation">,</span>
    src <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">,</span>
    minified_path_prefix <span class="syntax-punctuation">=</span> CDN_ASSET_PREFIX<span class="syntax-punctuation">,</span>
<span class="syntax-punctuation">)</span>

output_scan_test<span class="syntax-punctuation">(</span>
    name <span class="syntax-punctuation">=</span> <span class="syntax-string">&quot;cdn_base_path_exists_test&quot;</span><span class="syntax-punctuation">,</span>
    rules <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:cdn_base_path_exists&quot;</span><span class="syntax-punctuation">],</span>
    targets <span class="syntax-punctuation">=</span> <span class="syntax-punctuation">[</span><span class="syntax-string">&quot;:app_bundle&quot;</span><span class="syntax-punctuation">],</span>
<span class="syntax-punctuation">)</span></code></pre><p>This is the useful shift: the deployment script is no longer a pile of shell globbing. It is another consumer of the same verified artifact that tests and images consume.</p>
<h2>Selective Side Effects</h2>
<p>Build systems usually focus on selective computation: only rebuild what changed. Frontend deploys also need selective side effects.</p>
<p>Uploading assets, publishing sourcemaps, deploying preview workers, registering GraphQL persisted queries, or syncing extension bundles should not happen just because a broad CI script ran. They should happen because the artifact they consume actually changed.</p>
<p>That is the same idea behind changed-target CI pipelines: compute the affected build graph first, then run the expensive or external side effects only for the affected artifacts.</p>
<p>The practical benefits are not glamorous, but they matter:</p>
<ul>
<li>fewer unnecessary uploads</li>
<li>fewer flaky network operations</li>
<li>less CI time</li>
<li>lower risk of overwriting unchanged release artifacts</li>
<li>clearer audit trails for what changed</li>
</ul>
<p>The build graph gives the deployment layer a better trigger than &quot;some frontend file changed.&quot; It can say &quot;this exact bundle changed, so re-upload this exact artifact.&quot;</p>
<h2>Stamping And Preview Deploys</h2>
<p>Deployment artifacts often need release metadata: commit SHA, version, environment, service name, or release ID. That does not mean every frontend build should be stamped. Stamping too much can hurt cacheability.</p>
<p>A practical split is: build cacheable artifacts first, verify them, then stamp or label only the targets that publish or package them.</p>
<p>Preview environments deserve the same treatment. They often use the same verified bundle as production with different hostnames, asset prefixes, routes, worker names, credentials, and cleanup policies.</p>
<p>Those differences should be modeled, not hidden in a script branch. The best preview systems are boring because they are just another consumer of the artifact graph.</p>
<p>The frontend graph is not done at <code>dist</code>. It is done when the verified artifact is ready to run.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 7: Bazel vs Turborepo vs Nx</title>
      <link>https://longlho.github.io/posts/part-7-bazel-vs-turborepo-vs-nx/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-7-bazel-vs-turborepo-vs-nx/</guid>
      <description>Tool comparisons get annoying because they often ask the wrong question.</description>
      <content:encoded>
        <![CDATA[<p>Tool comparisons get annoying because they often ask the wrong question.</p>
<p>The useful question is not:</p>
<blockquote>
<p>Which monorepo tool is best?</p>
</blockquote>
<p>It is:</p>
<blockquote>
<p>Which model matches the problems this repository actually has?</p>
</blockquote>
<p>Bazel, Turborepo, and Nx are not three skins over the same idea. They operate at different levels of granularity.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart LR
  turbo[&quot;Turborepo\npackage tasks&quot;] --&gt; simple[&quot;Fast adoption&quot;]
  nx[&quot;Nx\nproject graph&quot;] --&gt; structure[&quot;Workspace structure&quot;]
  bazel[&quot;Bazel\naction/artifact graph&quot;] --&gt; precision[&quot;Precise artifacts&quot;]</pre></figure><h2>Turborepo: Package Task Orchestration</h2>
<p>Turborepo is strongest when package scripts already describe the work well.</p>
<p>It gives teams task orchestration, caching, and an easy adoption path. If the main problem is &quot;do not rerun unchanged <code>build</code>, <code>test</code>, and <code>typecheck</code> scripts,&quot; Turborepo is often the right answer.</p>
<p>That is a real need. Many frontend monorepos do not need custom build rules. They need to run familiar package scripts in the right order and avoid repeating work.</p>
<p>The tradeoff is granularity. Turborepo is fundamentally task-centered. You can add scripts for generated clients, output checks, and deployment packaging, but the model remains package-task orchestration. If a production artifact needs several typed intermediate artifacts and transitive metadata collections, those usually become scripts around the core model.</p>
<h2>Nx: Project Graph And Workspace Tooling</h2>
<p>Nx adds a richer project graph, affected workflows, generators, framework integrations, and module boundary rules.</p>
<p>It is a strong fit when a repository wants more structure than package scripts but still wants tooling close to common frontend workflows. Nx generators can make project creation consistent. Affected commands can make CI much smarter. Module boundary rules can encode architecture directly in workspace tooling.</p>
<p>The tradeoff is that custom artifact pipelines may need plugins or escape hatches. If the repository&#39;s needs fit the Nx project model, it is productive. If the build becomes highly custom, the model may stretch.</p>
<p>For many frontend-heavy teams, Nx lands in a useful middle: more structure than package scripts, less custom infrastructure than Bazel.</p>
<h2>Bazel: Action And Artifact Graph</h2>
<p>Bazel models targets, actions, inputs, outputs, providers, aspects, execution platforms, and caches.</p>
<p>That is more machinery. The reason to tolerate it is precision:</p>
<ul>
<li>generated packages as artifacts</li>
<li>transitive metadata with aspects</li>
<li>strict dependency declarations</li>
<li>typed config targets</li>
<li>output verification</li>
<li>server/image/deploy targets</li>
<li>cross-language contracts</li>
</ul>
<p>The cost is real: steeper learning curve, more rule infrastructure, more BUILD metadata, and more need for build-platform ownership.</p>
<p>Bazel is usually not the cheapest initial choice. It starts to pay for itself when the repository already has artifact complexity that package tasks and project graphs struggle to express cleanly.</p>
<h2>The Real Tradeoff</h2>
<p>This comparison is mostly about granularity.</p>
<p>Turborepo asks: which package tasks should run?</p>
<p>Nx asks: which projects and tasks are affected?</p>
<p>Bazel asks: which targets and actions produce which artifacts from which inputs?</p>
<p>More granularity gives more precision, but it also exposes more complexity. If the repository does not need that precision, Bazel can feel like overhead. If the repository does need it, package-level task orchestration can become a pile of scripts.</p>
<p>The right answer depends on where the complexity already lives.</p>
<h2>The Frontend-Specific Lens</h2>
<p>For frontend monorepos, the dividing line is often generated and secondary artifacts.</p>
<p>If the repo mostly runs framework builds and tests, task orchestration may be enough. If it needs generated API packages, transitive translation extraction, icon sprites, output scans, server image assembly, and worker deploy targets, an artifact graph starts to look less optional.</p>
<p>That does not make Bazel universally better. It means Bazel is strongest when the build needs to understand things that are not naturally package scripts.</p>
<h2>A Practical Rule</h2>
<p>Use Turborepo when package-level tasks are the right abstraction.</p>
<p>Use Nx when project graph tooling and generators solve the workspace problem.</p>
<p>Use Bazel when the repository needs explicit artifact modeling and custom graph behavior.</p>
<p>These tools are not on one ladder. They solve different scaling problems.</p>
<p>The mistake is choosing Bazel because it is powerful. Choose it when the precision is worth the ownership cost.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Part 8: Operating The Build System As Product Infrastructure</title>
      <link>https://longlho.github.io/posts/part-8-operating-the-build-system-as-product-infrastructure/</link>
      <guid isPermaLink="true">https://longlho.github.io/posts/part-8-operating-the-build-system-as-product-infrastructure/</guid>
      <description>A large frontend build system is not a one-time migration. It is infrastructure people use every day.</description>
      <content:encoded>
        <![CDATA[<p>A large frontend build system is not a one-time migration. It is infrastructure people use every day.</p>
<p>It has users, APIs, documentation, errors, adoption curves, metrics, and maintenance costs. If it is painful, frontend engineers will route around it.</p>
<figure class="mermaid-diagram"><pre class="mermaid">flowchart TD
  rules[&quot;Rule APIs&quot;] --&gt; workflows[&quot;Daily workflows&quot;]
  docs[&quot;Docs&quot;] --&gt; workflows
  codegen[&quot;Generators&quot;] --&gt; workflows
  errors[&quot;Good errors&quot;] --&gt; workflows
  metrics[&quot;Metrics&quot;] --&gt; improve[&quot;Platform improvements&quot;]
  workflows --&gt; confidence[&quot;Confidence to change&quot;]
  improve --&gt; rules</pre></figure><h2>Build Rules Are APIs</h2>
<p>A package macro is an API. A test rule is an API. An app rule is an API. A deploy rule is an API.</p>
<p>Good rule APIs have stable names, clear inputs, useful defaults, understandable outputs, documented escape hatches, and actionable failures.</p>
<p>The common path should be boring: add a component, add a test, split a package, update a generated client, run one app, debug CI. Those workflows should not require deep knowledge of providers, runfiles, execution platforms, or action graphs.</p>
<h2>Documentation, Errors, And Metrics</h2>
<p>Strict systems need good docs and good errors.</p>
<p>A bad error says a target failed. A good error says which import is missing, which dependency list should change, and whether the problem is runtime, test, config, or generated code.</p>
<p>Useful metrics include CI duration, affected target count, cache hit rate, local test latency, dependency violations, bundle size trends, flaky test rate, and package split frequency.</p>
<p>These metrics are useful because they show whether the build system is helping the codebase change.</p>
<h2>Design For Deletion</h2>
<p>Build platforms accumulate compatibility layers: old macros, aliases, migration flags, temporary generated files, special-case targets, and deprecated wrappers.</p>
<p>Every temporary path should have a way out. Can you find all remaining users? Is there a migration target? What condition allows deletion?</p>
<p>Bazel queries and dependency metadata can make cleanup practical. Without that discipline, yesterday&#39;s migration helper becomes tomorrow&#39;s permanent complexity.</p>
<p>A good build system is not only easy to add to. It is possible to simplify.</p>
<h2>Avoid The Platform Bottleneck</h2>
<p>A build platform team should not have to approve every new package, test, app, or generated client.</p>
<p>The way out is self-service: generators, templates, documented patterns, and stable rule APIs. The platform team should spend most of its time improving the system, not translating every frontend change into build metadata by hand.</p>
<p>When common workflows are self-service, the build platform scales with the repository instead of becoming its narrowest point.</p>
<h2>Tighten Gradually</h2>
<p>Do not turn every strict check on at once.</p>
<p>Measure first. Warn before failing. Start with new packages. Add autofixes. Ratchet over time.</p>
<p>The point is a repository that is easier to change, not a purity contest.</p>
<h2>The Final Point</h2>
<p>This series has one recurring idea: large frontend monorepos are graph problems.</p>
<p>That graph includes source, tests, configs, generated clients, translations, sprites, bundles, output checks, servers, images, workers, and deployments.</p>
<p>Bazel is useful when teams need to model that graph precisely. But precision is not enough. The platform also needs ergonomics, codegen, documentation, good errors, metrics, and incremental adoption.</p>
<p>The thing you want is confidence: a change rebuilds the right things, tests the right things, ships the right artifacts, and leaves the rest alone.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>

