<?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/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Writing | BHPS Labs</title>
    <link>https://bhpslabs.com/en/blog</link>
    <description>Notes from the people at BHPS Labs on software development, security and operations: the tools, mistakes and methods we deal with on our own products.</description>
    <language>en</language>
    <atom:link href="https://bhpslabs.com/en/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Thu, 15 Oct 2026 00:00:00 GMT</lastBuildDate>
    <image>
      <url>https://bhpslabs.com/logo.png</url>
      <title>Writing | BHPS Labs</title>
      <link>https://bhpslabs.com/en/blog</link>
    </image>
    <item>
      <title>TanStack Start 1.0 is out: why we build on it and why I left Next.js</title>
      <link>https://bhpslabs.com/en/blog/tanstack-start-1-0</link>
      <guid isPermaLink="true">https://bhpslabs.com/en/blog/tanstack-start-1-0</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Jan Szotkowski</dc:creator>
      <category>tanstack</category>
      <category>tanstack-start</category>
      <category>react</category>
      <category>nextjs</category>
      <category>seo</category>
      <category>geo</category>
      <category>developer-experience</category>
      <description>After years of development, TanStack Start has a stable 1.0. What shipped, what Tanner Linsley says about it, and why two of our production projects, bhpslabs.com and udalist.app, already run on Start. Developer experience, the learning curve, and how SEO and GEO work on Start.</description>
      <content:encoded><![CDATA[<p><strong>TanStack Start 1.0 is officially stable.</strong> Tanner Linsley announced it on October 13, 2026 on the <a href="https://tanstack.com/blog/tanstack-start-1-0">TanStack blog</a>, and I had been waiting for it for a long time. Not because I wasn&#39;t using Start yet. I was. I was waiting until I could tell clients, colleagues, and anyone who asks: <em>it&#39;s done, it&#39;s stable, go ahead and build on it.</em></p>
<p>People ask me this a lot. &quot;Which frontend technology would you pick for a new project today?&quot; My answer has been the same for a while now: <strong>TanStack Start, plus the rest of TanStack.</strong> It&#39;s the first thing I reach for when I start a new project. And right now I don&#39;t have a single reason to change that.</p>
<p>In this article I&#39;ll sum up what 1.0 means and what Tanner himself says about it. Most of all, I&#39;ll show how we use Start in production on two projects: this website, <a href="https://bhpslabs.com">bhpslabs.com</a>, and the <a href="https://udalist.app">udalist.app</a> application. No theory, just real code.</p>
<h2 id="what-1-0-means">What &quot;1.0&quot; means<a href="#what-1-0-means" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Plenty of companies have been running Start in production for a long time, from small projects to enterprise teams. As Tanner admits, many of them shipped it while it was still in beta, even when the team tried to talk them out of it. We were in the same boat.</p>
<p>So 1.0 isn&#39;t about Start suddenly starting to work. It&#39;s a <strong>formal commitment to the API.</strong> You can pick Start, learn it, and build a product on it without worrying that six months from now someone will rework the whole way your application works. For a company betting money and people on a technology, that&#39;s the most important property of all.</p>
<p>Tanner is also open about the road from release candidate to 1.0 taking longer than he expected. In the meantime, the framework got work on the compiler, streaming, hydration, and the boundaries between server and client. Manuel Schiller and Florian Pellet focused on SSR performance and got roughly <strong>5.5× higher throughput</strong> in their benchmark. Christopher Horobin is the reason the router&#39;s extremely strong types don&#39;t grind your editor to a halt. None of this looks dramatic in a changelog, but you feel it in production.</p>
<h2 id="what-tanner-says-and-why-i-agree">What Tanner says (and why I agree)<a href="#what-tanner-says-and-why-i-agree" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>I recommend reading the whole post in the original. For those short on time, here are the points that resonate with me most.</p>
<h3 id="it-all-started-with-a-router">It all started with a router<a href="#it-all-started-with-a-router" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Start didn&#39;t begin as a framework. It began as a router that Tanner wanted to <strong>know everything</strong>: which paths exist, which parameters they need, which search params are valid, what a loader returns, and what context is available where. And he wanted your editor to tell you, after every change, what else needs to change.</p>
<p>A few years ago, plenty of people didn&#39;t believe that was possible. Today I think it&#39;s the single biggest reason to use TanStack Router and Start.</p>
<h3 id="a-different-path-than-everything-on-the-server">A different path than &quot;everything on the server&quot;<a href="#a-different-path-than-everything-on-the-server" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Tanner describes how the React ecosystem narrowed down to a single idea of what an application should look like. Next.js bet on React Server Components both as a framework foundation and as an ideology. Start goes another way: <strong>an application stays an application</strong>, with state, client-side navigation, and data caching. You add server rendering, streaming, and server functions where they make sense, without rethinking how your whole app works.</p>
<p>Each route can choose to render on the server, to only load its data on the server, or to run purely on the client. Server functions are typed and can be called from a loader or an event handler. Middleware can run on both the client and the server, and the context types carry through.</p>
<h3 id="rsc-as-an-option-not-an-obligation">RSC as an option, not an obligation<a href="#rsc-as-an-option-not-an-obligation" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>There&#39;s an interesting passage about React Server Components. The team spent a lot of time on them, and Start supports them, but without letting them take over the whole application. And the punchline: on tanstack.com they eventually <strong>turned them off</strong>, because it kept getting harder to reason about what was shipped where, for people and for AI models alike. Turning them off didn&#39;t mean rewriting the app. That&#39;s exactly the kind of freedom I want from a framework.</p>
<h3 id="host-it-anywhere">Host it anywhere<a href="#host-it-anywhere" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Start is built on Vite and Nitro (and now supports Rsbuild too), so deployment isn&#39;t tied to one provider. Netlify, Vercel, Cloudflare, Railway, Render, or your own Docker container running a Node server, which is what we do.</p>
<h3 id="types-help-ai-agents-too">Types help AI agents too<a href="#types-help-ai-agents-too" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>I think this is the most underrated argument. Strict types and explicit APIs were built for people, but <strong>they help AI agents just as much</strong>. When an agent changes a route parameter or a server function&#39;s input, TypeScript immediately shows it every place that no longer agrees with that change. On top of that, TanStack has started shipping so-called skills inside its npm packages (via TanStack Intent), so an agent doesn&#39;t have to rely on outdated training data. It reads current best practices straight from the package.</p>
<h2 id="why-i-left-next-js">Why I left Next.js<a href="#why-i-left-next-js" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>I used Next.js for a long time and I don&#39;t want to trash it. It&#39;s great at explaining and selling its ideas, and it taught a lot of people a lot. But more and more I felt like I was <strong>fighting the framework instead of building the product</strong>.</p>
<ul>
<li><code>&quot;use client&quot;</code> and <code>&quot;use server&quot;</code> directives decide where code runs, and the boundaries aren&#39;t always visible.</li>
<li>Caching changed fundamentally across several versions in a row, and it was hard to tell what was cached, where, and for how long.</li>
<li>The App Router mental model is hard for juniors (and honestly for seniors too): server components, client components, what&#39;s allowed to import what.</li>
<li>Type safety in routing is more of an add-on than a foundation. Search params are basically <code>string | string[] | undefined</code>.</li>
</ul>
<p>Start is the opposite. <strong>Components are components, routes are routes, and when I need the server, I say so explicitly.</strong> I don&#39;t need a second mental model of my app just to get through the day.</p>
<p>I&#39;ve also met Tanner in person in Amsterdam, and that conversation confirmed what I&#39;d sensed from the code for a long time: behind TanStack is someone who cares about tools that are good to use, not just tools that are easy to sell.</p>
<h2 id="developer-experience-and-the-learning-curve">Developer experience and the learning curve<a href="#developer-experience-and-the-learning-curve" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>In my view, Start&#39;s DX is a class above, and a few concrete things show it best.</p>
<p><strong>Typed routes without keeping them in your head.</strong> The router generates its own route tree (<code>routeTree.gen.ts</code>). A link to a page that doesn&#39;t exist or a missing parameter is a compile-time error, not a 404 in production. On udalist.app we have more than 80 <code>&lt;Link&gt;</code> components, and every one of them is type-checked.</p>
<p><strong>Search params as typed state.</strong> <code>validateSearch</code> (used by seven routes on Udalist) turns search params into a validated, typed object. Filters, pagination, or form state in the URL stop being a source of bugs.</p>
<p><strong>An explicit server.</strong> A server function is a plain function you call. You can see what goes in, what comes out, and that it runs on the server. No magic.</p>
<p><strong>The learning curve.</strong> Anyone who knows React and a bit of TypeScript is productive in Start within a few days. There&#39;s no new paradigm to learn, just the router. And if you know TanStack Query, you&#39;ll feel at home even faster. With the Next.js App Router, I&#39;ve watched experienced developers struggle for weeks.</p>
<h2 id="seo-and-geo-where-start-really-shines">SEO and GEO: where Start really shines<a href="#seo-and-geo-where-start-really-shines" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>We spent a lot of time on SEO and GEO (Generative Engine Optimization, meaning optimization for AI assistants like ChatGPT, Claude, or Perplexity) on this website. Start didn&#39;t get in our way once. Quite the opposite.</p>
<h3 id="metadata-on-every-route">Metadata on every route<a href="#metadata-on-every-route" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Every route has a <code>head()</code> function that returns the title, description, canonical, hreflang, Open Graph, and JSON-LD. It&#39;s typed, it knows the route&#39;s parameters, and it runs during SSR, so crawlers get finished HTML. Here&#39;s a simplified version of the route for this very article:</p>
<pre class="tm-code" tabindex="0" data-lang="tsx"><code class="language-tsx"><span class="th-token th-keyword">export</span> <span class="th-token th-keyword">const</span> <span class="th-token th-type">Route</span> = <span class="th-token th-function">createFileRoute</span>(<span class="th-token th-string">&#39;/$locale/blog/$slug&#39;</span>)({
    <span class="th-token th-property">beforeLoad</span>: ({ params }) =&gt; {
        <span class="th-token th-keyword">if</span> (!<span class="th-token th-function">findPost</span>(posts, params.<span class="th-token th-property">locale</span>, params.<span class="th-token th-property">slug</span>)) <span class="th-token th-keyword">throw</span> <span class="th-token th-function">notFound</span>();
    },
    <span class="th-token th-property">head</span>: ({ params }) =&gt; {
        <span class="th-token th-keyword">const</span> post = <span class="th-token th-function">findPost</span>(posts, params.<span class="th-token th-property">locale</span>, params.<span class="th-token th-property">slug</span>);
        <span class="th-token th-keyword">return</span> <span class="th-token th-function">pageHead</span>({
            <span class="th-token th-property">title</span>: post.<span class="th-token th-property">title</span>,
            <span class="th-token th-property">description</span>: post.<span class="th-token th-property">description</span>,
            <span class="th-token th-property">ogType</span>: <span class="th-token th-string">&#39;article&#39;</span>,
            alternates, <span class="th-token th-comment">// hreflang to the article&#39;s translation</span>
            <span class="th-token th-property">nodes</span>: [<span class="th-token th-function">blogPostingNode</span>({ <span class="th-token th-property">headline</span>: post.<span class="th-token th-property">title</span> <span class="th-token th-comment">/* … */</span> })], <span class="th-token th-comment">// JSON-LD</span>
        });
    },
    <span class="th-token th-property">component</span>: <span class="th-token th-type">ArticlePage</span>,
});</code></pre>
<p>Notice the <code>beforeLoad</code> as well. An old URL format redirects with a permanent 301, and an article that doesn&#39;t exist returns a real 404, not an empty page with a 200 status code. That matters a lot for SEO.</p>
<h3 id="prerendering-and-the-sitemap-right-in-the-config">Prerendering and the sitemap right in the config<a href="#prerendering-and-the-sitemap-right-in-the-config" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>We prerender static pages at build time, and the sitemap is generated automatically. On both projects it&#39;s a few lines in <code>vite.config.ts</code>:</p>
<pre class="tm-code" tabindex="0" data-lang="ts"><code class="language-ts"><span class="th-token th-function">tanstackStart</span>({
    <span class="th-token th-property">pages</span>: <span class="th-token th-function">getTanstackStartPages</span>(),
    <span class="th-token th-property">prerender</span>: { <span class="th-token th-property">enabled</span>: <span class="th-token th-literal">true</span>, <span class="th-token th-property">crawlLinks</span>: <span class="th-token th-literal">false</span>, <span class="th-token th-property">autoStaticPathsDiscovery</span>: <span class="th-token th-literal">false</span> },
    <span class="th-token th-property">sitemap</span>: { <span class="th-token th-property">enabled</span>: <span class="th-token th-literal">true</span>, <span class="th-token th-property">host</span>: <span class="th-token th-string">&#39;https://bhpslabs.com/&#39;</span> },
}),</code></pre>
<p>On Udalist, the prerender also has a filter that skips the signed-in part of the app (the dashboard and authentication). Marketing pages are static and lightning fast, while the app behind the login runs as a classic SPA.</p>
<h3 id="a-website-ai-understands-too">A website AI understands too<a href="#a-website-ai-understands-too" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>This is where it pays off that Start doesn&#39;t hide anything. The custom server entry (<code>src/server.ts</code>) is a plain <code>fetch</code> handler, and you can add whatever you need to it. Thanks to that, on bhpslabs.com we:</p>
<ul>
<li><strong>return the same page as Markdown</strong> when an AI agent asks for it with an <code>Accept: text/markdown</code> header. The agent doesn&#39;t have to parse HTML and gets clean text,</li>
<li><strong>build a Content-Security-Policy for each response</strong> from the hashes of its inline scripts, without <code>unsafe-inline</code>,</li>
<li><strong>serve files under <code>/.well-known/</code></strong> for agent discovery (a catalog, agent skills),</li>
<li>and on top of that, a <code>robots.txt</code> with rules for AI crawlers and an <code>llms.txt</code> that tells models what we do and when to recommend us.</li>
</ul>
<pre class="tm-code" tabindex="0" data-lang="ts"><code class="language-ts"><span class="th-token th-keyword">export</span> <span class="th-token th-keyword">default</span> {
    <span class="th-token th-keyword">async</span> <span class="th-token th-function">fetch</span>(request: <span class="th-token th-type">Request</span>) {
        <span class="th-token th-keyword">const</span> wellKnown = <span class="th-token th-keyword">await</span> <span class="th-token th-function">wellKnownResponse</span>(request);
        <span class="th-token th-keyword">if</span> (wellKnown) <span class="th-token th-keyword">return</span> wellKnown;

        <span class="th-token th-keyword">const</span> markdown = <span class="th-token th-function">wantsMarkdown</span>(request.<span class="th-token th-property">headers</span>.<span class="th-token th-property">get</span>(<span class="th-token th-string">&#39;accept&#39;</span>));
        <span class="th-token th-keyword">const</span> response = <span class="th-token th-keyword">await</span> <span class="th-token th-function">paraglideMiddleware</span>(request, () =&gt; handler.<span class="th-token th-property">fetch</span>(request));

        <span class="th-token th-keyword">if</span> (markdown) <span class="th-token th-keyword">return</span> <span class="th-token th-function">toMarkdownResponse</span>(response);
        <span class="th-token th-comment">// … CSP from inline script hashes, Vary, gzip</span>
        <span class="th-token th-keyword">return</span> response;
    },
};</code></pre>
<p>Udalist has a very similar entry: Paraglide middleware, security headers, Markdown for the home page, and <code>/.well-known/</code> files. Designed once, used twice.</p>
<h2 id="our-projects-in-production">Our projects in production<a href="#our-projects-in-production" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<h3 id="bhpslabs-com">bhpslabs.com<a href="#bhpslabs-com" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>This website runs on TanStack Start, TanStack Router, React 19, Vite, and Nitro, deployed as a Node server in Docker. It&#39;s bilingual (Czech and English) through Paraglide, and paths look like <code>/$locale/...</code>.</p>
<p>A few things worth mentioning:</p>
<ul>
<li><strong>The blog</strong> (yes, including this article) is loaded from Markdown files through a custom Vite plugin that validates them at build time and turns them into typed data. Translations of an article are linked by their folder, not their URL.</li>
<li><strong>The enquiry form</strong> sends data through a typed server function with validation and rate limiting:</li>
</ul>
<pre class="tm-code" tabindex="0" data-lang="ts"><code class="language-ts"><span class="th-token th-keyword">export</span> <span class="th-token th-keyword">const</span> sendAuditRequest = <span class="th-token th-function">createServerFn</span>({ <span class="th-token th-property">method</span>: <span class="th-token th-string">&#39;POST&#39;</span> })
    .<span class="th-token th-property">validator</span>((value: <span class="th-token th-type">unknown</span>): <span class="th-token th-type">SendInput</span> =&gt; {
        <span class="th-token th-keyword">if</span> (!<span class="th-token th-function">isInput</span>(value)) <span class="th-token th-keyword">throw</span> <span class="th-token th-keyword">new</span> <span class="th-token th-function">Error</span>(<span class="th-token th-string">&#39;Invalid input&#39;</span>);
        <span class="th-token th-keyword">return</span> value;
    })
    .<span class="th-token th-property">handler</span>(<span class="th-token th-keyword">async</span> ({ data }): <span class="th-token th-type">Promise</span>&lt;<span class="th-token th-type">SendResult</span>&gt; =&gt; {
        <span class="th-token th-keyword">if</span> (!<span class="th-token th-function">allow</span>(<span class="th-token th-function">getRequestIP</span>({ <span class="th-token th-property">xForwardedFor</span>: <span class="th-token th-literal">true</span> }) ?? <span class="th-token th-string">&#39;unknown&#39;</span>)) <span class="th-token th-keyword">return</span> { <span class="th-token th-property">status</span>: <span class="th-token th-string">&#39;unavailable&#39;</span> };
        <span class="th-token th-comment">// … validation and sending the email</span>
    });</code></pre>
<ul>
<li><strong>Static HTML, caching, and compression</strong> are handled through Nitro <code>routeRules</code>: fonts and assets with a long cache, HTML with revalidation, and precompressed JS and CSS.</li>
</ul>
<p>The result? <strong>Lighthouse gives bhpslabs.com 100 / 100 / 100 / 100</strong> across all four categories (Performance, Accessibility, Best Practices, SEO), plus <strong>Agentic Browsing 3 / 3</strong> in the new category that measures how well an AI agent can find its way around a site. That&#39;s not down to a single trick. It&#39;s the sum of everything above: prerendering, SSR without unnecessary JavaScript, metadata on every route, and a server you can extend with whatever you need. Start didn&#39;t get in the way of any of it.</p>
<h3 id="udalist-app">udalist.app<a href="#udalist-app" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p><a href="https://udalist.app">Udalist</a> collects photos and videos from events (weddings, parties, corporate events) into one shared gallery. Guests scan a QR code and upload straight from their phone&#39;s browser, with no app and no account. It&#39;s our flagship, and it&#39;s more of an application than a website, so it shows the other side of Start:</p>
<ul>
<li><strong>33 route files</strong> with pathless layouts: <code>_public</code> for marketing, <code>_guest</code> for guests, and <code>_secure</code> for signed-in users. Each group has its own layout and its own rules, while the URL stays clean.</li>
<li><strong>TanStack Query</strong> lives in the router context, so every loader and <code>beforeLoad</code> has access to it. It pulls data from a Kotlin backend and is used in around 25 files across the app.</li>
<li><strong>TanStack Form</strong> powers nine forms, from contact to event security settings.</li>
<li><strong>Seven languages</strong> through Paraglide, and <strong>Sentry</strong> through its official SDK for TanStack Start.</li>
<li><strong>A home page A/B test</strong> runs through a server function that reads a cookie and sets response headers.</li>
<li><strong>Nitro cache rules</strong> are typed against the list of routes the router generates. Rename or delete a route, and the cache config stops compiling:</li>
</ul>
<pre class="tm-code" tabindex="0" data-lang="ts"><code class="language-ts"><span class="th-token th-keyword">import</span> <span class="th-token th-keyword">type</span> { <span class="th-token th-type">FileRouteTypes</span> } <span class="th-token th-keyword">from</span> <span class="th-token th-string">&#39;../src/routeTree.gen&#39;</span>;

<span class="th-token th-keyword">type</span> <span class="th-token th-type">AppFullPath</span> = <span class="th-token th-type">FileRouteTypes</span>[<span class="th-token th-string">&#39;fullPaths&#39;</span>];

<span class="th-token th-keyword">const</span> <span class="th-token th-type">MARKETING_PATHS_WITH_LONG_CACHE</span> = [
    <span class="th-token th-string">&#39;/&#39;</span>,
    <span class="th-token th-string">&#39;/pricing&#39;</span>,
    <span class="th-token th-string">&#39;/faq&#39;</span>,
    <span class="th-token th-string">&#39;/blog/&#39;</span>,
    <span class="th-token th-comment">// …</span>
] <span class="th-token th-keyword">as</span> <span class="th-token th-keyword">const</span> <span class="th-token th-keyword">satisfies</span> <span class="th-token th-keyword">readonly</span> <span class="th-token th-type">AppFullPath</span>[];</code></pre>
<p>A typo in a path is no longer a silent bug in production, where a page suddenly ships without caching and without security headers. It&#39;s a red squiggle in the editor. That&#39;s exactly what I love about TanStack.</p>
<p>Both projects deliberately share the same foundation. We&#39;re a team of four, and we don&#39;t want to maintain two different stacks. Whatever we solve on one, we carry over to the other.</p>
<h2 id="the-ecosystem-is-the-biggest-advantage">The ecosystem is the biggest advantage<a href="#the-ecosystem-is-the-biggest-advantage" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Start is great on its own, but its real strength is that it isn&#39;t alone:</p>
<ul>
<li><strong>TanStack Query</strong> for server data and caching, the standard you already know,</li>
<li><strong>TanStack Router</strong> with type safety you won&#39;t find anywhere else,</li>
<li><strong>TanStack Form</strong> for typed forms without the ceremony,</li>
<li><strong>TanStack Table</strong> and <strong>Virtual</strong> for tables and long lists,</li>
<li>and more: Store, Pacer, DB, AI, Devtools, Markdown…</li>
</ul>
<p>All the libraries share the same philosophy: headless, type-safe, framework-agnostic (React, Solid, Vue, and more), and designed to work well together. Learn one, and the others feel familiar. And if you decide not to use one of them, nothing breaks.</p>
<p>The ecosystem is also backed by strong partners: Netlify, Vercel, Cloudflare, Railway, Render, Lovable, Clerk, WorkOS, Sentry, Prisma, and more. These aren&#39;t just logos on a website. They&#39;re SDKs, templates, and integrations you can use right away.</p>
<h2 id="what-isn-t-perfect">What isn&#39;t perfect<a href="#what-isn-t-perfect" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>So this isn&#39;t just a hymn of praise: Nitro v3, which Start runs on in production, is still in beta. It works for us without problems, but it&#39;s good to know. And some advanced features (RSC, for example) are still evolving, so if you want to build your whole application on them, keep that in mind.</p>
<p>Neither was a reason for us to put off Start.</p>
<h2 id="how-to-get-started">How to get started<a href="#how-to-get-started" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>If you&#39;ve been waiting for 1.0, now&#39;s the time:</p>
<pre class="tm-code" tabindex="0" data-lang="bash"><code class="language-bash"><span class="th-token th-command">pnpm</span> create @tanstack/start@latest</code></pre>
<p>Then I recommend the <a href="https://tanstack.com/start/latest">Start documentation</a>, and if you&#39;re migrating from Next.js, TanStack has a migration guide too. And as Tanner himself suggests: feel free to put an AI agent on it. With up-to-date skills from the packages, TypeScript, and tests, it gets surprisingly far.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>For me, TanStack Start 1.0 confirms something I&#39;ve known for a long time. A framework doesn&#39;t have to dictate what your whole application looks like. It can give you strong, typed, explicit tools and leave the decisions to you.</p>
<p>We run it in production on this website and on Udalist, and everything works as it should. Anyone who asks me what to pick for the frontend today gets a clear answer: <strong>TanStack Start. Plus the rest of TanStack.</strong></p>
<p>Thank you, Tanner, and thanks to the whole team behind TanStack. And if you&#39;re wondering whether and how to move to Start, <a href="https://bhpslabs.com/en/contact">get in touch</a>. We&#39;re happy to help.</p>]]></content:encoded>
    </item>
    <item>
      <title>From electrician to tech lead: From overalls to a monitor</title>
      <link>https://bhpslabs.com/en/blog/from-electrician-to-tech-lead</link>
      <guid isPermaLink="true">https://bhpslabs.com/en/blog/from-electrician-to-tech-lead</guid>
      <pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Jan Szotkowski</dc:creator>
      <category>career</category>
      <category>frontend</category>
      <category>mentoring</category>
      <description>Seven years ago I stood in the middle of a factory with oil on my hands and the feeling that my life was going nowhere. Today, at 26, I am a front-end tech lead. Nobody handed any of it to me.</description>
      <content:encoded><![CDATA[<h2 id="introduction">Introduction<a href="#introduction" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Seven years ago I stood in the middle of a factory, hands dirty with oil, overalls full of dust, machines roaring around me, and a feeling in my head that my life was going nowhere. Shifts dragged on one after another, and when it rained I got on my bike and pedalled home, soaked, exhausted, thinking that this was probably how it would be forever. I was not desperate, but I felt that I wanted more. Not because I thought I was better than the people around me. They were honest and hard-working, the same as me. I was never top of the class. I was not some exceptional talent. I was an ordinary kid who wanted to achieve something in life. One day I stopped in the rain, looked at myself, a dirty electrician on a bike, and said: “I do not want to do this for the rest of my life.” The wish for a change led me to programming, something I had never dreamed of and had no background for. Today, at 26, I am a front-end tech lead at Raynet CRM, one of the best CRM systems in Czechia and Slovakia. Nobody handed me any of it. Everything I have, I earned myself. It was not easy for me, the same as it was not easy for anyone in the factory, and I do not want this to sound as if I am something more. I just decided to go after my goal, even though I did not believe in myself at the start.</p>
<h2 id="beginnings-no-background-no-help">Beginnings: No background, no help<a href="#beginnings-no-background-no-help" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>I never thought I would be a programmer. As a kid I was not the one who spends hours at a computer or builds robots. I ran around outside, played football, and the real world interested me. Technology passed me by. After school I became an electrician, because it was practical and within reach. I could wire cables and fix a socket, but I knew nothing about programming. Life in the factory was a routine: get up, work, go home, sleep, and repeat. The people around me were great and hard-working, and I respected them. It was not about being better than them. I just felt that I wanted something else. I bought an old laptop, the kind that growled like a tractor and overheated after a while, and started searching the internet for what programming even is. I had nobody to point me in a direction. No friend, no acquaintance in the field. The first evenings were chaos. I sat there after a shift, eyes burning with tiredness, trying to understand what “Hello World” means in HTML. It felt like a foreign world. I did not believe in myself, but I told myself: “If other people managed it, I can manage it too.” And so I started.</p>
<h2 id="discipline-as-my-only-companion">Discipline as my only companion<a href="#discipline-as-my-only-companion" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Every day I set time aside. After a shift, when I came home dirty and exhausted, I told myself: “At least an hour.” Sometimes I got up at four in the morning, still in the dark, so I could get through a tutorial before work. It was as if I had two jobs. At the factory I was bolting and repairing things, and at home I was learning something new. I searched for answers on Google, went through YouTube videos, and read forums into the night. When I first understood CSS and saw a page change according to my code, I felt hope. Then came JavaScript, a completely different world. I spent hours staring at errors in the console, not understanding why it would not work. I often wanted to quit. I had impostor syndrome, the feeling that I was a fraud who was not cut out for this. But the drive to achieve something kept pushing me. I wanted to be proud of myself, of managing it on my own, of something nobody could take away from me. When I managed to get a simple calculator or an animation running, I told myself: “Maybe there is something to this.” Discipline kept me afloat. Even when I did not believe in myself, I did not give up.</p>
<h2 id="a-road-full-of-disappointment">A road full of disappointment<a href="#a-road-full-of-disappointment" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>After months of learning I decided to try for a job. I wrote a CV, an ordinary one, with no degrees, just a few sentences about what I had managed on my own. I sent it out. First ten, then twenty, and in the end about a hundred. And what came back? Silence. Now and then an email: “Thank you, but we are looking for someone with experience.” Or: “Without a degree, it will not work.” As a junior with an electrician’s past, I was invisible. Those moments wore me down. I sat at home in the evening, staring at another rejection, asking myself: “Why am I doing this?” Impostor syndrome grew stronger. I felt like a fraud who was only playing at being a programmer. Once I sat in the kitchen while it poured outside and told myself: “Maybe I am crazy.” I wanted to give up, go back to the overalls, back to something certain. Then it occurred to me what that would mean. No progress, just the factory. I wanted something I could be proud of. So I got up, opened another tutorial, and sent another CV. After six months, full of rejections and doubt, someone called. “We have a junior programmer position at Pickering Interfaces in Třinec. Do you want to try it?” That call changed everything.</p>
<h2 id="first-job-an-unwanted-project-and-an-international-team">First job: An unwanted project and an international team<a href="#first-job-an-unwanted-project-and-an-international-team" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>When I joined Pickering Interfaces in Třinec, a subsidiary of the British company Pickering, I felt my life moving. For the first time I had colleagues, and not just any colleagues. I worked on an international team, on a project that reached beyond Czechia. After months of learning alone it was liberating, but it was not easy. I came in as a replacement for a programmer who had left, and I inherited a project nobody wanted, forgotten code that nobody wanted to maintain or develop. As a newcomer I got what newcomers usually get: the hardest, least popular work. At the start I did not know where to begin. Impostor syndrome followed me. I was afraid I would fail. But I told myself: “I am different. I will learn it.” I got stuck in. I learned as I went, fixed bugs, and looked for a way through. Bit by bit I got that project under control, and today I am glad of it. They were not soft on me. They threw me in at the deep end, and that forced me to grow. I even got a whisky glass from the British Queen for my work. A small thing, but for me a symbol that hard work means something. After two years in Třinec I had to leave my hometown and move to Ostrava. A hard decision, but I knew another challenge was waiting.</p>
<h2 id="growing-at-raynet-crm-from-junior-to-tech-lead">Growing at Raynet CRM: From junior to tech lead<a href="#growing-at-raynet-crm-from-junior-to-tech-lead" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>In Ostrava I joined Raynet CRM as a junior programmer. I started at the bottom, but for the first time I had colleagues and mentors who helped me whenever I needed it. No question was stupid. As I put it: “The one who is afraid to ask stays a fool forever. The one who is not afraid is a fool for only a few minutes.” I was not afraid to ask. After years without support, that was a change. I had a team that pulled me forward. Mentors taught me to write better code and to think like a programmer. The discipline I had built joined up with their guidance, and I grew fast. In four years I went from junior to front-end tech lead, at 26. Today I design solutions and work on a top-tier CRM. My managers trust me, my opinion carries weight, and I have a say in how the team works. I am proud, not only for myself, but also because I can help my family and friends whenever they need me. Working with your head is demanding. Sometimes I miss ordinary work with my hands, bolting, repairing, something you can touch. But I know that what I do now matters. A few months ago I wrote a piece of code that programmers around the world can use, and they do use it. When I see that, I feel that my work reaches past my desk.</p>
<h2 id="what-is-next-vision-and-mentoring">What is next: Vision and mentoring<a href="#what-is-next-vision-and-mentoring" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>When I was an electrician, I did not believe that at 26 I would be a tech lead. Now I tell myself: “We will see where the road takes me yet.” I want to keep growing, in technology and in leadership. I dream of a bigger influence, maybe leading a larger team, taking part in breakthrough projects, or trying to build a business in IT. Maybe one day I will make my own application that helps people, the way Raynet helps now. Every day I learn something new, whether it is a framework or how to motivate a team. Now I am the mentor for my juniors at Raynet, and I enjoy it. I see myself in them: the doubts, the fear of whether they are up to it. I help them the way I once needed help, and seeing their progress keeps me going. The road taught me that we set our own limits, and I do not want to set them.</p>
<h2 id="closing-you-can-do-this-too">Closing: You can do this too<a href="#closing-you-can-do-this-too" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>My story is not about talent. I was not a genius, I had no background, and for a long time I had nobody to help me. I am no different from the people in the factory, or from anyone doing work they do not enjoy. I am not better. I just decided to step out of my comfort zone, even though I did not believe in myself. At the start I had impostor syndrome. I did not believe I was up to it, and I wanted to quit. But I did not give up. From the factory, through Pickering Interfaces in Třinec, where I worked for a British company, to Raynet CRM in Ostrava: from zero to tech lead at 26. I am proud that what I achieved is mine, and that I can support my family and friends. People often do not step out of their comfort zone. They think they are not cut out for a change. I was the same. But I want to tell you: you can do this. The best time to start may have been a year ago. The second best time is now. You do not need a background. I did not have one. It is enough to start, to stick with it, and not to give up. I managed it, and you can manage it too. Take that first step. It is worth it.</p>]]></content:encoded>
    </item>
    <item>
      <title>AI slop and your codebase: Why you need a linter and a formatter more than ever</title>
      <link>https://bhpslabs.com/en/blog/linting-in-the-age-of-ai-slop</link>
      <guid isPermaLink="true">https://bhpslabs.com/en/blog/linting-in-the-age-of-ai-slop</guid>
      <pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Jan Szotkowski</dc:creator>
      <category>ai</category>
      <category>linting</category>
      <category>oxlint</category>
      <category>oxfmt</category>
      <category>eslint</category>
      <category>prettier</category>
      <category>coding-standards</category>
      <description>If you do not mind wasting money and time, do not read this article. AI writes code fast, but differently every time, and without clear rules a codebase falls apart. Why Oxlint and Oxfmt matter so much in the era of AI agents, and why speed is the part that pays off.</description>
      <content:encoded><![CDATA[<p><strong>If you do not mind paying developers to wait on a terminal, paying for CI minutes on a pipeline that runs half an hour longer than it has to, and paying for tokens while an AI agent fixes the same mistake for the third time, do not read this article.</strong> You are not missing anything. Money and time clearly do not matter to you.</p>
<p>Everyone else, keep reading.</p>
<p>Today AI can write a component for you in a minute. Twenty components in five minutes. It works, the tests pass, everyone is happy. Then you open the pull request and see that one component uses <code>interface</code>, another uses <code>type</code>, a third has a forgotten <code>console.log</code> in the middle, and a fourth imports the same library in three different ways.</p>
<p>None of those things is a bug. The code runs. But a codebase where every file looks like it was written by someone else is hard to read, hard to review and hard to maintain. That flood of &quot;it works, but nobody made it consistent&quot; is what we now call <strong>AI slop</strong>. And every hour you spend cleaning it up is an hour you pay for.</p>
<p>The good news: you can prevent it almost for free. The bad news: if you do it with slow tools, you only prevent it halfway, because people will start working around them.</p>
<p>And an important point up front: it is not only an AI problem.</p>
<h2 id="it-is-not-only-about-ai-it-is-just-more-visible">It is not only about AI, it is just more visible<a href="#it-is-not-only-about-ai-it-is-just-more-visible" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>In my article about the <a href="https://bhpslabs.com/en/blog/interface-vs-type">Interface vs. Type rule</a> I wrote about what happens when a team grows. Ten developers, ten slightly different habits. Someone brings <code>interface</code> over from Java, someone mixes both, and code review turns into a debate about whitespace instead of architecture.</p>
<p>An AI agent is basically ten more developers who have never met. It does not know your &quot;unwritten rule&quot;. Every session starts from zero, the model has learned thousands of different styles and picks whichever fits the moment. On top of that it writes <strong>faster than any of us</strong>, so inconsistencies grow faster than anyone can catch them.</p>
<p>The answer is the same as before, only more urgent: <strong>the rules have to be enforced by a machine, not by human memory.</strong></p>
<h2 id="a-rule-in-a-prompt-is-a-request-a-rule-in-a-linter-is-a-law">A rule in a prompt is a request, a rule in a linter is a law<a href="#a-rule-in-a-prompt-is-a-request-a-rule-in-a-linter-is-a-law" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Of course you can write into <code>AGENTS.md</code> or a system prompt: &quot;Use <code>type</code> instead of <code>interface</code>, do not use <code>any</code>, do not leave <code>console.log</code> behind.&quot; It often works. But it is only a request. The model forgets it once the context fills up, or interprets it its own way.</p>
<p>A lint rule works differently. It either passes or it fails. There is no discussion.</p>
<p>Modern AI agents also work in a loop:</p>
<ol>
<li>write code,</li>
<li>run checks (tests, types, lint),</li>
<li>read the errors,</li>
<li>fix them and go again.</li>
</ol>
<p>A linter and a formatter are exactly the feedback this loop needs. The agent does not have to guess what you like. It runs one command and gets a concrete answer: line 42, this rule, this is not allowed.</p>
<p>A formatter also removes a whole category of noise. Neither the agent nor the human has to think about quotes, indentation or trailing commas. The diff in a pull request contains only real changes, and the reviewer focuses on what the code does, not on how it looks.</p>
<h2 id="a-short-history-eslint-and-prettier">A short history: ESLint and Prettier<a href="#a-short-history-eslint-and-prettier" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>ESLint appeared in 2013 and Prettier in 2017. They changed the JavaScript world. ESLint guarded code quality (unused variables, dangerous constructs, custom team rules), and Prettier ended the eternal arguments about formatting by deciding for everyone.</p>
<p>The ESLint + Prettier pair became the standard and worked great for a decade. But it had one property we learned to live with: both tools are written in JavaScript and run on Node.js. On a small project nobody notices. On a large TypeScript codebase with type-aware rules, linting becomes something you run &quot;at the very end&quot; because it takes minutes.</p>
<h2 id="rust-changed-the-rules-of-the-game">Rust changed the rules of the game<a href="#rust-changed-the-rules-of-the-game" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Then came the <a href="https://oxc.rs">Oxc</a> project and with it <strong>Oxlint</strong> (2023) and later <strong>Oxfmt</strong>. Both are written in Rust, run natively and use every core of your CPU.</p>
<p>The difference in speed is not cosmetic. One developer on Reddit described migrating their company project (M3 MacBook Pro, median of three runs, over 6,000 files):</p>
<ul>
<li>ESLint (single-threaded, type-aware rules): <strong>~2 min 27 s</strong></li>
<li>Oxlint (5,360 files, 134 rules, 11 threads): <strong>~1.3 s</strong></li>
<li>Prettier: <strong>~13.9 s</strong></li>
<li>Oxfmt: <strong>~2.1 s</strong></li>
</ul>
<p>Mind that this is one specific project with one specific configuration, and your results will differ. The official numbers in the Oxc docs are more modest: Oxfmt is roughly 30x faster than Prettier. But the order of magnitude is the same everywhere. We are talking about tens and hundreds of times, not tens of percent.</p>
<p>And here is what has changed over the past year:</p>
<ul>
<li><strong>Oxfmt has been in beta since February 2026 and passes 100% of Prettier&#39;s conformance tests</strong> for JavaScript and TypeScript. The remaining differences are known bugs in Prettier itself. The output is not different, it is just faster.</li>
<li><strong>Type-aware linting has been stable since July 2026.</strong> It is powered by <code>tsgolint</code>, built on native TypeScript 7, and covers 59 of the 61 type-aware rules from typescript-eslint. This was for a long time the biggest excuse for staying on ESLint.</li>
<li>Oxlint now supports more than 800 rules from ESLint core and popular plugins (React, jsx-a11y, import, unicorn...) and can run JS plugins too.</li>
</ul>
<h2 id="speed-is-money">Speed is money<a href="#speed-is-money" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>I always say this, even though it sounds a bit dry: tooling speed is a business argument.</p>
<p>Let us run an illustrative example. A team of ten developers, each running checks ten times a day (before a commit, before a push, while fixing things after CI). With two and a half minutes of waiting per run that is <code>10 × 10 × 2.5 min</code>, roughly <strong>4 hours a day</strong> and about 80 hours a month spent staring at a terminal. With Oxlint (1.3 s per run) it adds up to under two minutes a day.</p>
<p>And that is before we count:</p>
<ul>
<li><strong>CI/CD pipelines.</strong> Every minute in a pipeline is paid for (runners) and waited for (developers). Faster linting means faster feedback on a pull request and faster releases.</li>
<li><strong>Pre-commit hooks.</strong> People start bypassing a hook that takes 40 seconds with <code>--no-verify</code>. Nobody disables a hook that takes a second.</li>
<li><strong>Delivery.</strong> A faster cycle means features and bug fixes reach clients sooner. That is what the customer asks about.</li>
</ul>
<h2 id="where-ai-fits-in">Where AI fits in<a href="#where-ai-fits-in" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>This is where the speed multiplies. A developer runs lint a few times a day. <strong>An AI agent runs it after every iteration</strong>, easily twenty times per task.</p>
<p>If every run takes two minutes, the agent either waits (and you wait with it, or pay for cloud agent runtime), or, which is worse, starts skipping the check. Slow feedback means the agent does more work blindly and then fixes more things at once.</p>
<p>As for tokens, let us be precise: waiting itself does not burn tokens. What burns them is the <strong>number of iterations and the size of the context</strong>. When the agent gets fast, precise feedback right after every change, it fixes the problem while it is still small. When it gets feedback only at the end, it has to re-read a bigger chunk of code, untangle dependencies and fix things it has since built on a wrong foundation. Depending on the model you use, you can see that on the invoice.</p>
<p>The result: <strong>a fast linter is to an AI agent what fast tests are to a human.</strong> As long as it takes seconds, it gets used. Once it takes minutes, people start walking around it.</p>
<h2 id="how-we-set-it-up">How we set it up<a href="#how-we-set-it-up" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Let us not stay in theory. This website runs on Oxlint and Oxfmt. The linter config looks like this:</p>
<pre class="tm-code" tabindex="0" data-lang="typescript"><code class="language-typescript"><span class="th-token th-keyword">import</span> { defineConfig } <span class="th-token th-keyword">from</span> <span class="th-token th-string">&#39;oxlint&#39;</span>;

<span class="th-token th-keyword">export</span> <span class="th-token th-keyword">default</span> <span class="th-token th-function">defineConfig</span>({
    <span class="th-token th-property">plugins</span>: [<span class="th-token th-string">&#39;import&#39;</span>, <span class="th-token th-string">&#39;unicorn&#39;</span>, <span class="th-token th-string">&#39;react&#39;</span>, <span class="th-token th-string">&#39;typescript&#39;</span>, <span class="th-token th-string">&#39;jsx-a11y&#39;</span>],
    <span class="th-token th-property">categories</span>: {
        <span class="th-token th-property">correctness</span>: <span class="th-token th-string">&#39;warn&#39;</span>,
    },
    <span class="th-token th-property">rules</span>: {
        <span class="th-token th-string">&#39;eslint/no-console&#39;</span>: [<span class="th-token th-string">&#39;error&#39;</span>, { <span class="th-token th-property">allow</span>: [<span class="th-token th-string">&#39;warn&#39;</span>, <span class="th-token th-string">&#39;error&#39;</span>] }],
        <span class="th-token th-string">&#39;@typescript-eslint/consistent-type-definitions&#39;</span>: [<span class="th-token th-string">&#39;error&#39;</span>, <span class="th-token th-string">&#39;type&#39;</span>],
        <span class="th-token th-string">&#39;@typescript-eslint/no-explicit-any&#39;</span>: <span class="th-token th-string">&#39;error&#39;</span>,
        <span class="th-token th-string">&#39;@typescript-eslint/no-unused-vars&#39;</span>: <span class="th-token th-string">&#39;error&#39;</span>,
    },
});</code></pre>
<p>Look at the middle line. The <code>consistent-type-definitions</code> rule, which I once wrote my own ESLint plugin for, is now <strong>one line of config</strong> and runs natively in Oxlint. The same goes for the accessibility rules (<code>jsx-a11y</code>) that AI loves to skip.</p>
<p>And so we do not have to think about three commands, there is one in <code>package.json</code>:</p>
<pre class="tm-code" tabindex="0" data-lang="json"><code class="language-json">{
    <span class="th-token th-property">&quot;scripts&quot;</span>: {
        <span class="th-token th-property">&quot;lint&quot;</span>: <span class="th-token th-string">&quot;oxlint&quot;</span>,
        <span class="th-token th-property">&quot;fmt&quot;</span>: <span class="th-token th-string">&quot;oxfmt&quot;</span>,
        <span class="th-token th-property">&quot;cq&quot;</span>: <span class="th-token th-string">&quot;oxlint &amp;&amp; oxfmt --check &amp;&amp; tsc --noEmit&quot;</span>
    }
}</code></pre>
<p><code>cq</code> (code quality) runs lint, the format check and types. The same command runs in CI. The same command is what we tell the agent. One sentence in the agent instructions (<code>AGENTS.md</code>, <code>CLAUDE.md</code>, rules) is enough:</p>
<pre class="tm-code" tabindex="0" data-lang="md"><code class="language-md">Before you call the work done, run <span class="th-token th-code-inline">`pnpm run cq`</span> and fix every finding.</code></pre>
<p>That turns a &quot;recommendation&quot; into a gate the code has to pass, whether a human or a model wrote it.</p>
<h2 id="it-is-not-100-there-yet-and-i-would-still-not-reach-for-anything-else">It is not 100% there yet (and I would still not reach for anything else)<a href="#it-is-not-100-there-yet-and-i-would-still-not-reach-for-anything-else" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>I do not want to write an advert, so let me be straight: Oxlint and Oxfmt are not yet a 100% replacement for ESLint and Prettier. A few things you may run into:</p>
<ul>
<li><strong>Not every rule exists.</strong> Oxlint covers more than 800 rules, but some special rule from an ESLint plugin you depend on may be missing.</li>
<li><strong>Custom ESLint plugins.</strong> Local plugins are not migrated automatically, but you can add them by hand to the config through <code>jsPlugins</code>. Oxlint supports most of the ESLint v9 API, not all of it.</li>
<li><strong>Prettier plugins and less common formats.</strong> Oxfmt is in beta and Prettier plugin support is still catching up. If you depend on the exact behavior of a specific plugin, check it in advance.</li>
<li><strong>Type-aware linting needs TypeScript 7.</strong> On an older version that is one more step.</li>
</ul>
<p>So what do you do? My position is simple: <strong>until something better comes along, I would not reach for anything other than Oxlint and Oxfmt.</strong> Not because they are perfect, but because the speed difference is so large that a few missing rules or small deviations can be worked around, while a slow tool in your pipeline never can. Development is also moving fast: in a few months Oxfmt went from alpha to a beta with full compatibility on JS and TS, and type-aware linting became stable.</p>
<p>If you really miss a rule, you can bridge it by running Oxlint first and leaving ESLint only for what remains (<code>oxlint &amp;&amp; eslint</code>, and the <code>eslint-plugin-oxlint</code> plugin turns off duplicates). Treat that as a temporary bridge, not the end state.</p>
<h2 id="how-to-start-in-an-afternoon">How to start (in an afternoon)<a href="#how-to-start-in-an-afternoon" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Migration is surprisingly painless today.</p>
<p>Linter:</p>
<pre class="tm-code" tabindex="0" data-lang="bash"><code class="language-bash"><span class="th-token th-command">pnpm</span> add -D oxlint
<span class="th-token th-command">npx</span> @oxlint/migrate</code></pre>
<p>Formatter:</p>
<pre class="tm-code" tabindex="0" data-lang="bash"><code class="language-bash"><span class="th-token th-command">pnpm</span> add -D oxfmt@latest
<span class="th-token th-command">pnpm</span> oxfmt --migrate=prettier
<span class="th-token th-command">pnpm</span> oxfmt</code></pre>
<p>Keep the bulk reformat as a separate commit and add its hash to <code>.git-blame-ignore-revs</code> so <code>git blame</code> does not point at &quot;who reformatted the whole project&quot;. Then update CI and pre-commit hooks, and finally put the command into your AI agent instructions.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>In a world where a model writes much of the code, consistency is not a luxury. It is the one thing that keeps a codebase readable for humans and for the next AI agents that come after you.</p>
<p>Rules have to be <strong>automatic</strong> (we do not want to think about them all the time), <strong>enforced</strong> (no &quot;I will be more careful next time&quot;) and <strong>fast</strong> (otherwise everyone avoids them). ESLint and Prettier taught us that. Oxlint and Oxfmt took it to the point where a check takes a second, not minutes. And that pays off whether you are paying for developer time, CI minutes or tokens.</p>
<p>If lint is the step you run &quot;at the very end&quot; today, try switching it. It takes an afternoon. And then tell me how many seconds you got back.</p>]]></content:encoded>
    </item>
    <item>
      <title>NPM Apocalypse 2025: How to sleep when the ecosystem is collapsing</title>
      <link>https://bhpslabs.com/en/blog/npm-apocalypse-2025</link>
      <guid isPermaLink="true">https://bhpslabs.com/en/blog/npm-apocalypse-2025</guid>
      <pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Jan Szotkowski</dc:creator>
      <category>npm</category>
      <category>security</category>
      <category>renovate</category>
      <category>dependencies</category>
      <category>vulnerabilities</category>
      <description>Year 2025 was brutal for NPM package security. Hundreds of compromised libraries, millions of malware downloads. How do you defend yourself? You will not manage it by hand. Here is my strategy with Renovate bot.</description>
      <content:encoded><![CDATA[<p>If you follow the JavaScript world, you could not have missed it. Year 2025 was a complete disaster for the security of the NPM ecosystem.</p>
<p>We are not talking about a handful of cases. We are talking about <strong>hundreds of packages</strong> that were compromised. Attackers got into maintainer accounts (often through social engineering or leaked tokens) and smuggled malicious code into legitimate, widely used libraries.</p>
<p>Some of those packages had millions of downloads a week. Picture it: you come into work on Monday morning, run <code>npm install</code>, and without suspecting a thing you pull malware into the project. It steals your ENV variables or injects a cryptominer into your users&#39; browsers.</p>
<p>It is frightening. And honestly, if you are relying on luck, it is only a matter of time before it hits you too.</p>
<h2 id="four-layers-of-manual-defence">Four layers of manual defence<a href="#four-layers-of-manual-defence" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>The first instinctive reaction is: <em>&quot;I&#39;ll stop using NPM!&quot;</em>
That is a nice thought, but in modern frontend development it is about as realistic as building a skyscraper with a hammer and nails. We cannot do without the React, TanStack, or Tailwind ecosystem today.</p>
<p>There are steps I take to keep the risk down:</p>
<h3 id="1-minimalism-and-forking">1. Minimalism and forking<a href="#1-minimalism-and-forking" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Before I add a new dependency, I ask: <em>&quot;Do I really need a whole library to format a date?&quot;</em>
If it is a small utility, I would rather write it myself. If it is a more complex library that still carries a lot of ballast, I often make a <strong>fork</strong>. I take only the part of the code I need and put it straight into my repository. That cuts the cord to future updates (which may be infected).</p>
<h3 id="2-disabling-scripts-ignore-scripts">2. Disabling scripts (<code>ignore-scripts</code>)<a href="#2-disabling-scripts-ignore-scripts" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Most of these attacks rely on <code>postinstall</code> scripts. Those are commands that run automatically right after the package is downloaded. Attackers love them. It is the perfect place to hide a <code>curl</code> that sends your <code>.env</code> files to their server.</p>
<p>The defence is simple. In the project root (next to <code>package.json</code>) add an <code>.npmrc</code> file with this content:</p>
<pre class="tm-code" tabindex="0" data-lang="ini"><code class="language-ini"><span class="th-token th-property">ignore-scripts</span>=<span class="th-token th-literal">true</span></code></pre>
<p>That disables these scripts globally. Yes, it occasionally breaks the install of a legitimate tool (esbuild or Cypress, for example), but you can fix that by allowlisting specific packages. The security is worth it.</p>
<h3 id="3-pnpm-and-versions-that-have-been-out-for-a-while">3. pnpm, and versions that have been out for a while<a href="#3-pnpm-and-versions-that-have-been-out-for-a-while" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>npm downloads a package the minute someone publishes it. An attacker ships a version, you run the install in that same moment, and it is on your machine.</p>
<p>pnpm, from version 12, does not do that by default. It has a rule called <code>minimumReleaseAge</code>: it only downloads a version that has been on the registry for more than 24 hours. The value is in minutes, and the default is <code>1440</code> (one day). Most of those compromised releases are found and removed from the registry within the first few hours. A day&#39;s delay cuts you off from them before you download them.</p>
<p>A day is not enough for me. I want versions that are 7 to 14 days old, that have been out for a while, and that nobody has had to yank as malware. You set it in <code>pnpm-workspace.yaml</code>. Seven days is <code>10080</code> minutes, fourteen is <code>20160</code>:</p>
<pre class="tm-code" tabindex="0" data-lang="yaml"><code class="language-yaml"><span class="th-token th-comment"># 7 days. If the project can wait two weeks, put 20160 here.</span>
<span class="th-token th-property">minimumReleaseAge</span>: <span class="th-token th-string">10080</span></code></pre>
<p>This applies to transitive dependencies too, not only what you listed in <code>package.json</code>. You can still install a newer version when its turn comes. It just will not be the one that came out ten minutes ago.</p>
<h3 id="4-explicitly-pinned-versions">4. Explicitly pinned versions<a href="#4-explicitly-pinned-versions" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Writing <code>&quot;react&quot;: &quot;^19.1.0&quot;</code> in <code>package.json</code> does not say &quot;I want React 19.1.0&quot;. It says &quot;take anything from 19.1.0 up to just below 20&quot;. The caret and the tilde are open doors. Delete the lockfile, run <code>pnpm update</code> or start the project from scratch, and a version you have never seen ends up in your project. That is exactly how most of the compromised releases of 2025 spread: through ranges that happily accepted them.</p>
<p>So I write versions exactly, with no <code>^</code> or <code>~</code>:</p>
<pre class="tm-code" tabindex="0" data-lang="json"><code class="language-json">{
    <span class="th-token th-property">&quot;dependencies&quot;</span>: {
        <span class="th-token th-property">&quot;react&quot;</span>: <span class="th-token th-string">&quot;19.1.0&quot;</span>,
        <span class="th-token th-property">&quot;@tanstack/react-router&quot;</span>: <span class="th-token th-string">&quot;1.120.3&quot;</span>
    }
}</code></pre>
<p>So that it does not depend on my memory, I tell the package manager too. In <code>pnpm-workspace.yaml</code>:</p>
<pre class="tm-code" tabindex="0" data-lang="yaml"><code class="language-yaml"><span class="th-token th-property">saveExact</span>: <span class="th-token th-string">true</span></code></pre>
<p>With npm, the line <code>save-exact=true</code> in <code>.npmrc</code> does the same. Add a lockfile committed to the repository and <code>pnpm install --frozen-lockfile</code> in CI, so the build never reaches for anything that is not in the lockfile. The Renovate config below has <code>&quot;rangeStrategy&quot;: &quot;pin&quot;</code> for the same reason. When it does bump a version, it writes it exactly again.</p>
<p>Security is not the only reason, though. The second one comes from Brussels.</p>
<p>The <strong>Cyber Resilience Act (CRA)</strong>, EU Regulation 2024/2847, applies to every product with digital elements sold on the EU market. That includes the web applications and SaaS you deliver to clients. From September 2026 manufacturers must report actively exploited vulnerabilities, and from December 2027 the obligations apply in full. One of them: know and document the components of your product, among other things in the form of an <strong>SBOM</strong> (Software Bill of Materials). That is a machine-readable list of all dependencies and their versions, typically in the CycloneDX or SPDX format.</p>
<p>This is where pinning pays off a second time. An SBOM is useless if it does not match what actually runs in production. With <code>^</code> ranges, <code>package.json</code> is just a wish. Only exact versions and a locked lockfile guarantee that Monday&#39;s build and Friday&#39;s build contain the same thing, and that the SBOM generated in CI describes exactly what you deployed. When an advisory for a specific version comes out, you look at the SBOM and know within a minute whether it affects you. That is exactly what the CRA asks of manufacturers.</p>
<p>You can then generate the SBOM on every build, for example with CycloneDX&#39;s <a href="https://github.com/CycloneDX/cdxgen">cdxgen</a>:</p>
<pre class="tm-code" tabindex="0" data-lang="bash"><code class="language-bash"><span class="th-token th-command">npx</span> @cyclonedx/cdxgen -o sbom.json</code></pre>
<p>The CRA does not literally say &quot;pin your versions&quot;, but without them the requirements for an overview of your components are hard to meet.</p>
<h2 id="automation-when-a-person-is-not-enough">Automation: when a person is not enough<a href="#automation-when-a-person-is-not-enough" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Manual steps are fine, but they have one weakness: me. I forget. I do not have time every morning to read security reports and check whether <code>lodash</code> 4.17.21 happens to be vulnerable.</p>
<p>I spent a long time looking for a tool that would watch my back. I needed something that:</p>
<ol>
<li>Runs automatically (CI/CD).</li>
<li>Knows the vulnerability databases (GitHub Security Advisories, OSV).</li>
<li>Will not bother me with updates I do not need, but will shout when it is a security issue.</li>
</ol>
<p>The winner was Renovate Bot.</p>
<h2 id="my-security-first-config">My &quot;security-first&quot; config<a href="#my-security-first-config" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Most people know Renovate as the tool that bumps package versions for you. &quot;A new React version is out? Here is a PR.&quot;</p>
<p>I use it differently. On stable projects I do not want the bot &quot;breaking&quot; the app with minor updates I do not need. I want it to stay quiet and speak up only when my current version contains a security hole.</p>
<p>This is the configuration I am testing right now, and it lets me sleep:</p>
<pre class="tm-code" tabindex="0" data-lang="json"><code class="language-json">{
    <span class="th-token th-property">&quot;$schema&quot;</span>: <span class="th-token th-string">&quot;[https://docs.renovatebot.com/renovate-schema.json](https://docs.renovatebot.com/renovate-schema.json)&quot;</span>,
    <span class="th-token th-property">&quot;extends&quot;</span>: [<span class="th-token th-string">&quot;group:turboMonorepo&quot;</span>, <span class="th-token th-string">&quot;:semanticCommits&quot;</span>],
    <span class="th-token th-property">&quot;timezone&quot;</span>: <span class="th-token th-string">&quot;Europe/Prague&quot;</span>,
    <span class="th-token th-property">&quot;prConcurrentLimit&quot;</span>: <span class="th-token th-number">10</span>,
    <span class="th-token th-property">&quot;prHourlyLimit&quot;</span>: <span class="th-token th-number">0</span>,
    <span class="th-token th-property">&quot;dependencyDashboard&quot;</span>: <span class="th-token th-literal">true</span>,
    <span class="th-token th-property">&quot;dependencyDashboardApproval&quot;</span>: <span class="th-token th-literal">false</span>,
    <span class="th-token th-comment">// This is where it starts: turn on security alerts</span>
    <span class="th-token th-property">&quot;vulnerabilityAlerts&quot;</span>: {
        <span class="th-token th-property">&quot;enabled&quot;</span>: <span class="th-token th-literal">true</span>,
        <span class="th-token th-property">&quot;labels&quot;</span>: [<span class="th-token th-string">&quot;security&quot;</span>, <span class="th-token th-string">&quot;vulnerability&quot;</span>]
    },
    <span class="th-token th-property">&quot;osvVulnerabilityAlerts&quot;</span>: <span class="th-token th-literal">true</span>,
    <span class="th-token th-property">&quot;automerge&quot;</span>: <span class="th-token th-literal">false</span>,
    <span class="th-token th-property">&quot;rangeStrategy&quot;</span>: <span class="th-token th-string">&quot;pin&quot;</span>,
    <span class="th-token th-property">&quot;schedule&quot;</span>: [<span class="th-token th-string">&quot;at any time&quot;</span>],
    <span class="th-token th-comment">// Rule that disables ALL updates...</span>
    <span class="th-token th-property">&quot;packageRules&quot;</span>: [
        {
            <span class="th-token th-property">&quot;description&quot;</span>: <span class="th-token th-string">&quot;Disable all non-security updates&quot;</span>,
            <span class="th-token th-property">&quot;matchUpdateTypes&quot;</span>: [<span class="th-token th-string">&quot;major&quot;</span>, <span class="th-token th-string">&quot;minor&quot;</span>, <span class="th-token th-string">&quot;patch&quot;</span>, <span class="th-token th-string">&quot;pin&quot;</span>, <span class="th-token th-string">&quot;digest&quot;</span>, <span class="th-token th-string">&quot;lockFileMaintenance&quot;</span>, <span class="th-token th-string">&quot;rollback&quot;</span>, <span class="th-token th-string">&quot;bump&quot;</span>, <span class="th-token th-string">&quot;replacement&quot;</span>],
            <span class="th-token th-property">&quot;enabled&quot;</span>: <span class="th-token th-literal">false</span>
        }
    ]
}</code></pre>
<h3 id="how-does-it-work">How does it work?<a href="#how-does-it-work" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h3>
<p>Look at the packageRules section. It explicitly says: &quot;Disable (enabled: false) every update type (major, minor, patch...).&quot;</p>
<p>If I stopped there, Renovate would do nothing. But because I have vulnerabilityAlerts and osvVulnerabilityAlerts turned on above, Renovate still checks the vulnerability databases.</p>
<p>The result?</p>
<ul>
<li>axios v1.8.0 comes out (a new feature)? Silence. Renovate leaves me alone.</li>
<li>It turns out axios v1.7.0 has a security hole? ALARM. Renovate immediately opens a pull request with the fix to a safe version and tags it as security.</li>
</ul>
<p>Wire this config into a GitHub or GitLab CI pipeline and run it once a day (or every hour).</p>
<p>That way I know that even while I am asleep, my repository is being compared with the latest threat databases. It is not 100% protection (that does not exist), but it is much better than hoping it will pass me by.</p>]]></content:encoded>
    </item>
    <item>
      <title>Interface vs. Type: Why I wrote my own ESLint rule (and how it changed our team)</title>
      <link>https://bhpslabs.com/en/blog/interface-vs-type</link>
      <guid isPermaLink="true">https://bhpslabs.com/en/blog/interface-vs-type</guid>
      <pubDate>Tue, 23 Dec 2025 00:00:00 GMT</pubDate>
      <dc:creator>Jan Szotkowski</dc:creator>
      <category>typescript</category>
      <category>eslint</category>
      <category>interface</category>
      <category>type</category>
      <category>coding-standards</category>
      <description>As a front-end developer with more than six years of experience, I have been through plenty of TypeScript debates. One that always starts a passionate argument is the classic Interface vs. Type dilemma.</description>
      <content:encoded><![CDATA[<p>As a front-end developer with more than six years of experience, I have been through plenty of TypeScript debates. One that always starts a passionate argument is the classic dilemma of “Interface vs. Type”. Early in my career I did not think about it much. Both seemed to do the job. But as projects grew and teams got bigger, I realised that small decisions like this can make the codebase consistent, or break that consistency. That is why I eventually wrote my own ESLint rule, to enforce type aliases instead of interface declarations in our TypeScript codebase. In this article I will share why I decided to do it, how I built the rule, and how it changed the way we work. I will also throw in a few tips so you can try it yourself.</p>
<h2 id="chaos-when-a-team-scales-without-clear-rules">Chaos when a team scales without clear rules<a href="#chaos-when-a-team-scales-without-clear-rules" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>When I started working with TypeScript at my company, the team was small, just a handful of developers. We had a verbal agreement to stick to type aliases instead of interface declarations, because they felt more predictable (more on that later). At first it worked beautifully. We were aligned, the code was clean, and life was fine. Then the team grew fast. Within a year we had 10 developers contributing to the same codebase, and that is when things got complicated.</p>
<p>New teammates were not always aware of our “unwritten rule”. Some preferred interface, because it felt more familiar from their Java background. Others mixed both approaches without even noticing. Code reviews turned into endless debates about style rather than substance. It was clear we needed a better way to enforce consistency, something automated, reliable, and able to scale. So I decided to roll up my sleeves and build my own ESLint plugin: <code>eslint-plugin-interface-to-type</code>. It would automatically enforce type instead of interface across the whole codebase and save us from ourselves.</p>
<h2 id="why-i-chose-type-over-interface">Why I chose type over interface<a href="#why-i-chose-type-over-interface" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Before we get into the technical details, let us talk about why I chose <code>type</code> over <code>interface</code> in the first place. At a glance they look interchangeable. Both describe the shape of an object, right? But there is a subtle difference that can bite you on a larger project: declaration merging.</p>
<p>In TypeScript, if you declare two interface definitions with the same name, they are merged into one automatically. Here is a quick example:</p>
<pre class="tm-code" tabindex="0" data-lang="typescript"><code class="language-typescript"><span class="th-token th-keyword">interface</span> <span class="th-token th-type">User</span> {
    id: <span class="th-token th-type">number</span>;
}

<span class="th-token th-keyword">interface</span> <span class="th-token th-type">User</span> {
    name: <span class="th-token th-type">string</span>;
}

<span class="th-token th-comment">// TypeScript merges them into:</span>
<span class="th-token th-keyword">interface</span> <span class="th-token th-type">User</span> {
    id: <span class="th-token th-type">number</span>;
    name: <span class="th-token th-type">string</span>;
}</code></pre>
<p>That behaviour can be useful in some cases, for example when extending types from third-party libraries. In a collaborative codebase it is a recipe for confusion. Picture two developers unknowingly declaring conflicting properties in separate files. That is a bug waiting to happen. Type aliases, on the other hand, do not merge. If you try to declare two <code>type</code> aliases with the same name, TypeScript throws a compile error, which forces you to resolve the conflict up front. For me, that predictability is a lifesaver.</p>
<p>Besides declaration merging, type aliases are more flexible. They can represent unions, intersections, and primitives, things that <code>interface</code> struggles with. Interface still has its place (for example when you need to extend a class), but I found that <code>type</code> is the safer default for most of our use cases.</p>
<h2 id="building-the-eslint-rule-from-chaos-to-consistency">Building the ESLint rule: From chaos to consistency<a href="#building-the-eslint-rule-from-chaos-to-consistency" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Once I had a clear reason to enforce type, I needed a tool that would do it automatically. That is where my ESLint plugin, <code>eslint-plugin-interface-to-type</code>, comes in. I wanted a rule that would flag any interface declaration, suggest replacing it with a type alias, and even offer an autofix to speed the process up.</p>
<p>Writing a custom ESLint rule was a bit scary at first. I had never written one. After some research I got the hang of it. The plugin uses the ESLint AST (Abstract Syntax Tree) to detect interface declarations and transform them into equivalent type aliases. For example, this:</p>
<pre class="tm-code" tabindex="0" data-lang="typescript"><code class="language-typescript"><span class="th-token th-keyword">interface</span> <span class="th-token th-type">User</span> {
    id: <span class="th-token th-type">number</span>;
    name: <span class="th-token th-type">string</span>;
    role: <span class="th-token th-string">&#39;admin&#39;</span> | <span class="th-token th-string">&#39;user&#39;</span>;
}</code></pre>
<p>gets flagged, and it can be fixed automatically to:</p>
<pre class="tm-code" tabindex="0" data-lang="typescript"><code class="language-typescript"><span class="th-token th-keyword">type</span> <span class="th-token th-type">User</span> = {
    <span class="th-token th-property">id</span>: <span class="th-token th-type">number</span>;
    <span class="th-token th-property">name</span>: <span class="th-token th-type">string</span>;
    <span class="th-token th-property">role</span>: <span class="th-token th-string">&#39;admin&#39;</span> | <span class="th-token th-string">&#39;user&#39;</span>;
};</code></pre>
<p>The hardest part was the edge cases, such as interface declarations that extend other interfaces, or ones with complex generics. I spent hours tuning the rule so it stayed compatible and avoided false positives. Another challenge was the autofix. The ESLint <code>--fix</code> option needs exact transformations, so I had to map interface syntax onto type syntax carefully, without breaking the code.</p>
<p>Once the plugin was done, setting it up was straightforward. Install it as a dev dependency:</p>
<pre class="tm-code" tabindex="0" data-lang="bash"><code class="language-bash"><span class="th-token th-command">npm</span> install eslint-plugin-interface-to-type --save-dev</code></pre>
<p>Then add it to your ESLint config:</p>
<pre class="tm-code" tabindex="0" data-lang="json"><code class="language-json">{
    <span class="th-token th-property">&quot;plugins&quot;</span>: [<span class="th-token th-string">&quot;interface-to-type&quot;</span>],
    <span class="th-token th-property">&quot;rules&quot;</span>: {
        <span class="th-token th-property">&quot;interface-to-type/prefer-type-over-interface&quot;</span>: <span class="th-token th-string">&quot;error&quot;</span>
    }
}</code></pre>
<p>Run ESLint with <code>--fix</code> and it converts your interface declarations into type aliases. Done.</p>
<h2 id="impact-on-how-we-work-less-arguing-more-building">Impact on how we work: Less arguing, more building<a href="#impact-on-how-we-work-less-arguing-more-building" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>The effect on the team was immediate. We used to spend chunks of code review arguing about interface vs. type. Now the rule enforces consistency for us and frees up mental space for discussions that matter more, such as architecture or performance.</p>
<p>It also saved us time. New developers no longer needed a long introductory lecture about our style preferences. The rule simply handled it. Code reviews got faster, because we were no longer nitpicking syntax. It is a small change, but the knock-on effect was huge.</p>
<h2 id="tips-for-developers-how-to-make-this-work-for-your-team">Tips for developers: How to make this work for your team<a href="#tips-for-developers-how-to-make-this-work-for-your-team" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>If the idea appeals to you, here are a few tips to help you enforce type, or any coding standard, in your projects:</p>
<ol>
<li>Understand your needs first. Before you enforce type instead of interface, make sure it matches what your team is trying to do. If you rely heavily on declaration merging or on class implementations, interface may still have a place. For us, type worked better in 99% of cases, so it made sense to standardise on it.</li>
<li>Automate it with tools such as ESLint. Do not rely on verbal agreements. They do not scale. Tools such as ESLint (or Prettier for formatting) can enforce rules consistently across the whole team. Writing your own rule can sound intimidating, but it is a great thing to learn, and it pays off in the long run.</li>
<li>Get the team on board. If you are introducing a new standard, get feedback from your colleagues first. Before I rolled the ESLint rule out, I shared it with the team, and their input helped me catch a few edge cases I had missed.</li>
</ol>
<h2 id="try-it-yourself-and-let-me-know">Try it yourself and let me know<a href="#try-it-yourself-and-let-me-know" aria-hidden="true" class="heading-anchor" tabindex="-1">#</a></h2>
<p>Switching to type aliases and enforcing them with a custom ESLint rule was a real change for my team. The code became more predictable, reviews more productive, and onboarding smoother. If you are dealing with similar problems, or you just want to experiment with stricter standards, I recommend trying it.</p>
<p>Start by installing <code>eslint-plugin-interface-to-type</code> and running it on your codebase. See what it feels like to have a consistent approach to types in TypeScript. Do you see interface vs. type differently? Or did the rule surface unexpected problems in your project?</p>]]></content:encoded>
    </item>
  </channel>
</rss>
