<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Vandré Leal Cândido · Writing</title><description>Thoughts on architecture, tooling, and craft.</description><link>https://vandre.dev/</link><language>en-us</language><item><title>Rebuilding SentiNEO as a Desktop</title><link>https://vandre.dev/blog/rebuilding-sentineo-as-a-desktop/</link><guid isPermaLink="true">https://vandre.dev/blog/rebuilding-sentineo-as-a-desktop/</guid><description>Why I rebuilt an asteroid tracker from a scrolling list into a desktop of draggable windows, and why the phone version had to be a different interface.</description><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SentiNEO tracks the asteroids passing close to Earth. For its first couple of years it was one page with a list on it. I have just rebuilt it as a desktop of windows you can drag around, and the reason had nothing to do with the framework.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-with-a-list&quot;&gt;The problem with a list&lt;/h2&gt;
&lt;p&gt;The old version showed the week’s close approaches as rows. You read one, then the next, and the page holds a single thing in front of you at a time.&lt;/p&gt;
&lt;p&gt;That does not match the questions people bring to it. Is this asteroid closer than that one. Is four lunar distances a lot. Does this orbit cross Earth’s at a steep angle or barely at all. Each of those needs two things on screen together, and a list gives you one.&lt;/p&gt;
&lt;h2 id=&quot;why-windows&quot;&gt;Why windows&lt;/h2&gt;
&lt;p&gt;So the new version has nine panels and lets you arrange your own screen.&lt;/p&gt;
&lt;p&gt;A window is the cheapest thing I could build that puts the feed next to one asteroid’s orbit, or two asteroids side by side. “Which of these two is closer” is the sort of question the old version could not answer at all.&lt;/p&gt;
&lt;p&gt;I could have picked the arrangement myself and shipped a fixed grid. I did not, because no single arrangement suits both someone checking tonight’s sky and someone reading through the risk catalog.&lt;/p&gt;
&lt;h2 id=&quot;the-layout-lives-in-the-url&quot;&gt;The layout lives in the URL&lt;/h2&gt;
&lt;p&gt;Once someone has arranged four panels the way they want them, that arrangement is worth keeping. It has to survive a refresh, a bookmark, and a link sent to someone else.&lt;/p&gt;
&lt;p&gt;So the layout sits in the address bar: which windows are open, which one has focus, and what each one is showing. Nothing is stored on a server and there are no saved-layout ids.&lt;/p&gt;
&lt;p&gt;What I got wrong was the testing. When the layout is a URL, every link is a promise about what you will see, and a broken promise still returns a valid page. No error, no failed request, nothing in the console. Someone follows the link and gets a screen that looks fine and is not the one they were sent.&lt;/p&gt;
&lt;p&gt;That happened to me. Links I had published pointed at the wrong panels for a while and nothing complained. What catches it now is a test that walks every link the site publishes about itself and checks each page against what the link claimed.&lt;/p&gt;
&lt;h2 id=&quot;the-phone-needed-its-own-interface&quot;&gt;The phone needed its own interface&lt;/h2&gt;
&lt;p&gt;Windows only work when there is room to put them side by side, and a phone has none. There is also nothing to drag them with.&lt;/p&gt;
&lt;p&gt;So the phone got a different interface instead of a smaller one. One panel at a time, full width, a tab bar and swipes to move between them, and a flat radar in place of the 3D globe. Same data, same links, different shape.&lt;/p&gt;
&lt;p&gt;That sounds like a compromise and it is the same reasoning as the desktop. Windows were worth it on a wide screen because there was room to answer two questions at once. On a phone there is no room, so keeping them would have kept the look and lost the point.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-from-it&quot;&gt;What I took from it&lt;/h2&gt;
&lt;p&gt;The rewrite was worth doing because I could state the problem in one sentence: the old version read well and compared badly. That is small enough to hold in your head while making a thousand decisions, and specific enough to say no with.&lt;/p&gt;
&lt;p&gt;Every scope question got the same test. Does this help someone put two things next to each other and decide something.&lt;/p&gt;</content:encoded><category>Architecture</category><category>UI</category><category>Patterns</category></item><item><title>Deploying Swagger UI for Bacen&apos;s Pix API</title><link>https://vandre.dev/blog/deploying-swagger-ui-for-bacens-pix-api/</link><guid isPermaLink="true">https://vandre.dev/blog/deploying-swagger-ui-for-bacens-pix-api/</guid><description>A pull request to the Brazilian central bank that was closed without ever being merged, and shipped anyway. Rendering an OpenAPI spec as docs, from CI.</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two weeks before Pix went live, the Brazilian central bank published its API as a single &lt;code&gt;openapi.yaml&lt;/code&gt; in a public repository. The spec was complete and well written. It just had no rendered form, so reading it meant downloading the file and pasting it into an editor.&lt;/p&gt;
&lt;p&gt;I opened &lt;a href=&quot;https://github.com/bacen/pix-api/pull/151&quot;&gt;a pull request&lt;/a&gt; adding twenty-five lines of CI and one new file.&lt;/p&gt;
&lt;h2 id=&quot;the-shape-of-the-fix&quot;&gt;The shape of the fix&lt;/h2&gt;
&lt;p&gt;The workflow runs on every push to the default branch. One &lt;a href=&quot;https://github.com/marketplace/actions/swagger-ui-action&quot;&gt;action&lt;/a&gt; renders &lt;code&gt;openapi.yaml&lt;/code&gt; into a static Swagger UI bundle, and a second publishes that bundle to the &lt;code&gt;gh-pages&lt;/code&gt; branch. No server, no hosting cost, nothing to operate. GitHub was already storing the spec and already serving Pages. The twenty-five lines just connect two things the repository had.&lt;/p&gt;
&lt;p&gt;The point is not that the documentation exists. It is that it regenerates from the spec on every push, so it cannot drift from the contract it describes. A rendered copy someone refreshes by hand is a second source of truth waiting to disagree with the first. This one has no separate lifecycle to forget about.&lt;/p&gt;
&lt;h2 id=&quot;link-the-outcome-not-the-diff&quot;&gt;Link the outcome, not the diff&lt;/h2&gt;
&lt;p&gt;The pull request did not only describe the result. I ran the same workflow on my own fork and linked the live page, so anyone reading could see the rendered spec before deciding whether twenty-five lines of YAML were worth taking.&lt;/p&gt;
&lt;p&gt;A diff asks a reviewer to imagine an outcome. A link hands it to them. For a change this small, imagining it was most of the work, so removing that was most of the value.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The pull request was never merged. A maintainer replied that the central bank had no policy for accepting community code, and five days later posted a link to &lt;a href=&quot;https://bacen.github.io/pix-api/&quot;&gt;the rendered spec&lt;/a&gt;, live. They had implemented it themselves. I suggested they also put the URL in the repository’s About panel, and they did that too.&lt;/p&gt;
&lt;p&gt;The page is still up almost six years later, documenting the API behind a payment system most of Brazil now uses daily.&lt;/p&gt;
&lt;p&gt;So I stopped counting merged pull requests as the measure of a contribution. Where a project has no route for outside code, the most useful thing to send is not a patch. It is a working demonstration, small enough that rebuilding it costs less than reviewing it.&lt;/p&gt;</content:encoded><category>OpenAPI</category><category>CI</category><category>Documentation</category><category>Tooling</category></item><item><title>What My Thesis Assumed About Hardware</title><link>https://vandre.dev/blog/what-my-thesis-assumed-about-hardware/</link><guid isPermaLink="true">https://vandre.dev/blog/what-my-thesis-assumed-about-hardware/</guid><description>Five years on from my paper about coordinating jobs across weak devices. The hardware assumption aged badly. The reliability assumption did not age at all.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In 2021 I published a paper with my advisor at CLOSER on a tool I had built, &lt;a href=&quot;https://www.scitepress.org/Papers/2021/104677/104677.pdf&quot;&gt;Runlet: a cross-platform IoT tool for interactive job execution over heterogeneous devices with reliable message delivery&lt;/a&gt;. I wanted to start a job on one machine, watch it run on another, and answer a prompt halfway through, even when the two machines shared no operating system and the network between them was bad.&lt;/p&gt;
&lt;p&gt;I reread it this week. Half of what I argued has held up and half has not, and the line between them is cleaner than I expected.&lt;/p&gt;
&lt;h2 id=&quot;the-machines-i-built-it-for&quot;&gt;The machines I built it for&lt;/h2&gt;
&lt;p&gt;My test bench gives away every assumption I was making. Three single-board computers: one from 2012 with a single 700 MHz core and 512 MB of memory, one at 900 MHz with a gigabyte, and the newest at 1.4 GHz, also a gigabyte. Two laptops sat alongside them doing the managing.&lt;/p&gt;
&lt;p&gt;That gap was my whole premise. I treated the small devices as things you send work to and read results from, because I did not think they could hold the coordination logic themselves. So I put coordination in a broker and gave each device an agent that talked to it. Every decision in the architecture follows from believing a device was too small to be trusted with much.&lt;/p&gt;
&lt;p&gt;The demo I was proudest of was running &lt;code&gt;htop&lt;/code&gt; on one of those boards from a laptop across the network, function keys and all, with the whole interactive session carried over a message queue instead of a shell connection.&lt;/p&gt;
&lt;h2 id=&quot;the-machines-that-arrived-instead&quot;&gt;The machines that arrived instead&lt;/h2&gt;
&lt;p&gt;A board you can buy today has four cores at 2.4 GHz and comes with up to sixteen gigabytes of memory. Against the newest device on my bench that is roughly forty times the memory, several times the throughput per core, and a move from a 32-bit instruction set to a 64-bit one.&lt;/p&gt;
&lt;p&gt;The comparison that stings is not with the boards. It is with the laptops. The 2013 machine I used to manage the fleet had two cores at 2.6 GHz and eight gigabytes. A board costing a fraction of what that laptop did would win most of that comparison now. The thing I was managing caught up with the thing I was managing it from.&lt;/p&gt;
&lt;p&gt;So my premise weakened. A device that runs containers, holds a real database and does inference locally does not obviously need something else deciding what it should run next. Designing this today, I would push most of the decisions out to the edge and treat the broker as a channel rather than a controller.&lt;/p&gt;
&lt;h2 id=&quot;the-part-i-would-not-change&quot;&gt;The part I would not change&lt;/h2&gt;
&lt;p&gt;Everything I wrote about reliability I would publish again unedited.&lt;/p&gt;
&lt;p&gt;Devices still do not agree on how to talk to each other. The networks between them still fail in the way that actually matters, which is not slowness but disappearance. A queue that survives a node dying mid-job, and a broker that comes back from a restart still holding what it had, are worth what they were worth then.&lt;/p&gt;</content:encoded><category>Architecture</category><category>IoT</category><category>Patterns</category></item><item><title>Cutting Backend CI by 76%</title><link>https://vandre.dev/blog/cutting-backend-ci-by-76-percent/</link><guid isPermaLink="true">https://vandre.dev/blog/cutting-backend-ci-by-76-percent/</guid><description>A Django test pipeline went from a median of 18.5 minutes to 4.5. Almost none of it came from making tests faster. The last part was a connection budget.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A Django backend I work on ran CI in a median of 18.5 minutes, p90 22.3. It now runs in 4.5, p90 8.0, measured over 90 runs on the main branch.&lt;/p&gt;
&lt;p&gt;Almost none of that came from making a test faster. That is worth saying first, because the instinct when CI is slow is to go hunting for slow tests. There were slow tests. They were not the problem.&lt;/p&gt;
&lt;p&gt;The time went on work the pipeline repeated because nobody told it not to, work it ran in sequence because that was the default, and one resource limit nobody had written down.&lt;/p&gt;
&lt;h2 id=&quot;where-the-time-went&quot;&gt;Where the time went&lt;/h2&gt;
&lt;p&gt;Two costs dominated, and neither was a test.&lt;/p&gt;
&lt;p&gt;Every job replayed all 843 migrations in sequence across four database aliases before running anything, about 4:20 each time. That was paid on every job of every run and had nothing to do with the change being tested. The schema those migrations produce depends only on the migration files, and those change a few times a week.&lt;/p&gt;
&lt;p&gt;The second was a legacy suite of 2035 tests taking about 15:30 on a single two-vCPU runner. There was no speeding that up in place, because the limit was a core count rather than a function. You cannot profile your way out of not having enough cores.&lt;/p&gt;
&lt;p&gt;Repeated work that need not repeat, and sequential work that need not be sequential. Different problems with different fixes, and neither one shows up in a profiler pointed at the tests.&lt;/p&gt;
&lt;h2 id=&quot;stop-running-what-you-do-not-need&quot;&gt;Stop running what you do not need&lt;/h2&gt;
&lt;p&gt;The cheapest minutes came from work that should never have started.&lt;/p&gt;
&lt;p&gt;The repository holds a frontend tree the backend tests never read, so the backend jobs now check out without it. Change detection runs per job, so a pull request touching one subsystem does not pay for the others, and the change-detection gate skips checkout entirely, because working out what changed does not need the files. A run that touches nothing relevant finishes in about 1.6 minutes, most of which is scheduling.&lt;/p&gt;
&lt;p&gt;I also moved performance profiling and the N+1 query report off the pull request path and onto a nightly schedule. Both are useful. Neither answers a question you need answered before merging. Every check on that path is a tax on every engineer on every push, and the bar for staying there should be that somebody would act on it now.&lt;/p&gt;
&lt;p&gt;The last fixed cost was tool bootstrap. Our runner image was rebuilt to include the language toolchain and build headers each job had been installing for itself. Dropping those steps saved 15 to 25 seconds a job. On its own that is nothing. Times six shards plus the nightly job, on every run, it stops being nothing. Fixed costs are worth attacking exactly when you have fanned out, because fanning out multiplies them.&lt;/p&gt;
&lt;h2 id=&quot;cache-the-schema-shard-the-suite&quot;&gt;Cache the schema, shard the suite&lt;/h2&gt;
&lt;p&gt;The migration replay became a cache. Dump the fully migrated schemas, key the cache on the contents of the migration files, restore before the run. Restore takes about 2.4 seconds against the 4:20 it replaces, and a cache miss rebuilds by migrating the four aliases at once instead of one after another, so even the bad path beats the old good path.&lt;/p&gt;
&lt;p&gt;Keying on file contents rather than a version string matters. The cache invalidates exactly when the schema could have changed and never otherwise. I put that hash in one module so the test fixtures and the cache scripts compute it the same way. Two places deciding separately whether the schema changed is how you get a cache that is quietly wrong.&lt;/p&gt;
&lt;p&gt;The legacy suite became a matrix. The detail I would keep in any version of this is that the sharding runner does nothing unless an environment variable is set, so running tests locally is exactly what it was before. A speedup that changes how everyone works locally is not free, whatever the CI graph says.&lt;/p&gt;
&lt;p&gt;My first attempt split the shards round-robin over discovery order, which assumes every test class costs the same. They do not, and a matrix finishes when its slowest shard finishes, so an unbalanced split wastes most of what sharding bought. I replaced it with longest-processing-time bin packing over recorded per-class timings. Then I found the timings left out &lt;code&gt;setUpClass&lt;/code&gt; and &lt;code&gt;setUpTestData&lt;/code&gt;, which for database-heavy classes is most of the cost, and folded those in. Balancing on a measurement I had not checked was its own small lesson.&lt;/p&gt;
&lt;h2 id=&quot;the-limit-nobody-had-budgeted&quot;&gt;The limit nobody had budgeted&lt;/h2&gt;
&lt;p&gt;The last piece was a flake rather than a speedup. The suite would die every so often on “sorry, too many clients already”.&lt;/p&gt;
&lt;p&gt;The cause was arithmetic. Automatic worker detection fanned out to roughly 55 workers, each holding a connection to all four aliases, against a Postgres container capped at 100. It passed whenever the timing happened to stay under the cap, which is the worst kind of green: not a broken test, not a reliable failure, just a coin flip that got worse as the suite got faster.&lt;/p&gt;
&lt;p&gt;The fix took four passes, and the order mattered more than where it ended up. Cap the workers to stop the bleeding. Measure actual peak connections under load instead of reasoning about what they should be. Raise the server limit now that the real number is known. Then raise the workers again against the new limit, and settle one step below the fastest setting, because the fastest one traded a few seconds of median for a tail-latency problem.&lt;/p&gt;
&lt;p&gt;Workers times aliases times connections per alias has to fit under the limit. That is a capacity plan, and it belongs in a comment next to the number it justifies, because the next person to see a green pipeline and a spare core will raise it back.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Look for repeated work and unbudgeted resources before looking for slow code. The wins here were a cache, some deletions, a bin-packing problem and a connection budget, and not one of them touched a test.&lt;/p&gt;
&lt;p&gt;The habit underneath is asking what a pipeline pays for on every run that only changes weekly, and what it pays for that nobody reads. Migration replay was the first. Profiling on every pull request was the second. Both had been there long enough to look like the cost of doing business.&lt;/p&gt;</content:encoded><category>CI</category><category>Performance</category><category>Testing</category></item><item><title>One Source of Truth for Fair-Usage Limits</title><link>https://vandre.dev/blog/one-source-of-truth-for-fair-usage-limits/</link><guid isPermaLink="true">https://vandre.dev/blog/one-source-of-truth-for-fair-usage-limits/</guid><description>A pricing policy is only as real as the number of systems that agree on it. Nine published usage bands, one table, and three codebases reading it.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A fair-usage policy is a promise. Here is what your plan includes, and here is what it costs when you go past it. Writing that down is easy. Making it true means every system that computes against it gives the same answer, and that is the part nobody budgets for.&lt;/p&gt;
&lt;p&gt;The chat platform I work on publishes nine usage bands keyed to annual spend. Each band includes five allowances, and going past any of them is billed as an overage: $5.00 per million stored messages, $100.00 per million stored channels, $0.40 per gigabyte of egress, $7.00 per million API calls, $2.50 per hundred thousand query-channel calls. All of it is &lt;a href=&quot;https://getstream.io/chat/docs/react/fair-usage-limits/&quot;&gt;published in the docs&lt;/a&gt;, so a customer can read the table, do the arithmetic and predict their bill.&lt;/p&gt;
&lt;p&gt;Every number there is a commitment. Any system that computes a different one is a bug with a dollar value attached.&lt;/p&gt;
&lt;h2 id=&quot;a-sentinel-in-a-public-table&quot;&gt;A sentinel in a public table&lt;/h2&gt;
&lt;p&gt;The top two bands are the complication. Two of their five allowances are negotiated per customer rather than fixed, so the published table shows them as “Custom”. Internally that needs a sentinel, and a sentinel is only safe when every consumer knows it exists: any code comparing a usage figure against a limit has to skip those metrics rather than treat the sentinel as a threshold.&lt;/p&gt;
&lt;p&gt;That is the general hazard. A value one reader treats as “not applicable” and another reads as an ordinary threshold does not fail loudly. It gives you a confident, plausible, wrong answer, and it does it at the end of the range where the fewest records will show you the mistake.&lt;/p&gt;
&lt;h2 id=&quot;one-table-read-live&quot;&gt;One table, read live&lt;/h2&gt;
&lt;p&gt;Getting the table right was not the same as fixing the problem. Two copies that agree today are still two copies.&lt;/p&gt;
&lt;p&gt;An early draft of the backend endpoint mirrored the Go table in Python, with a comment telling the next person to edit both. That is not a source of truth. So the bands and the overage rates moved into reference tables in the shared platform database, with an editing UI, and the finance side of the business owns the values directly.&lt;/p&gt;
&lt;p&gt;The backend reads those tables live, cached for five minutes, with no local models and no copy of its own. The dashboard reads the backend. One ladder, three codebases and two languages between the people who set the numbers and the customer who sees them, and no step in that chain holds a duplicate it could edit on its own.&lt;/p&gt;
&lt;p&gt;The cost is a runtime dependency where a constant used to be. That meant designing the unavailable case rather than discovering it: bands that cannot be resolved return a clear error, and missing rates degrade to an empty list instead of a broken page.&lt;/p&gt;
&lt;h2 id=&quot;guidance-not-a-cap&quot;&gt;Guidance, not a cap&lt;/h2&gt;
&lt;p&gt;The part I would keep in any future version of this is the part that lets the rule be broken.&lt;/p&gt;
&lt;p&gt;Plan limits default from the band, and a bulk tool can reset every plan back to its band figures. But a limit set off-band, above or below, is kept rather than clamped. Saving one asks for explicit confirmation, resolving the band from the price being submitted so it stays correct when the price changes in the same edit, and the deviation is recorded as a warning.&lt;/p&gt;
&lt;p&gt;Negotiated exceptions are not policy violations. They are the business. A system that clamps them quietly reverses agreements someone made on purpose. The reconcile tool is deliberately not on a schedule for the same reason: a job that reset limits to their band every night would eventually erase a deliberate decision, and nobody would know which one.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The engineering here is unremarkable. Some tables, an endpoint, a cache, a management command. The judgment is in deciding which number is the real one and then refusing to keep a second copy anywhere, including the copy that would have been convenient.&lt;/p&gt;
&lt;p&gt;A useful test for any policy that spans systems: pick a number a customer could read off your public documentation, then count how many places compute it. If the answer is more than one, that policy describes what your systems happen to do, not a rule they follow.&lt;/p&gt;</content:encoded><category>Architecture</category><category>API</category><category>Patterns</category></item><item><title>queryFn vs select: One Transform, Two Consumers</title><link>https://vandre.dev/blog/queryfn-vs-select/</link><guid isPermaLink="true">https://vandre.dev/blog/queryfn-vs-select/</guid><description>Transforms in select look textbook until route loaders start prefetching. Why the transform moved into queryFn so observers and loaders read the same shape.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;TanStack Query gives you two places to transform a response. You can do it in the &lt;code&gt;queryFn&lt;/code&gt;, before the data enters the cache. Or in &lt;code&gt;select&lt;/code&gt;, as each observer reads it out.&lt;/p&gt;
&lt;p&gt;An observer is a mounted &lt;code&gt;useQuery&lt;/code&gt;. Hold onto that, because it turns out to be the whole story.&lt;/p&gt;
&lt;p&gt;The transforms are the boring kind every API layer collects: flatten an envelope, parse date strings into dates, rename fields into the language the app uses. The two spots look interchangeable. They are not, and you only find out when something other than a component reads the cache.&lt;/p&gt;
&lt;h2 id=&quot;the-textbook-move&quot;&gt;The textbook move&lt;/h2&gt;
&lt;p&gt;A data-fetching audit on a dashboard I work on moved the transforms out of &lt;code&gt;queryFn&lt;/code&gt; and into &lt;code&gt;select&lt;/code&gt;. That is &lt;a href=&quot;https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations&quot;&gt;the documented default&lt;/a&gt;, for good reasons. The cache keeps the raw server shape, transforms run per observer with structural sharing, and two components can derive different views of the same entry without refetching.&lt;/p&gt;
&lt;p&gt;The migration shipped. It was fine for about a month.&lt;/p&gt;
&lt;h2 id=&quot;the-consumer-select-never-meets&quot;&gt;The consumer select never meets&lt;/h2&gt;
&lt;p&gt;The dashboard prefetches on navigation. Route loaders call &lt;code&gt;ensureQueryData&lt;/code&gt; with the same query options the components use, and &lt;code&gt;select&lt;/code&gt; never runs for those. Loaders, prefetches and any imperative &lt;code&gt;getQueryData&lt;/code&gt; read all get the raw shape, while mounted components get the transformed one. Same query key, two shapes, depending on who is asking.&lt;/p&gt;
&lt;p&gt;That is a quiet lie in the type system. Anything typed against the query’s data that runs in a loader is typed against a value it will never see. It works until a loader inspects the data it just fetched, say a redirect that keys on a field only the transform produces, and then it breaks a long way from the cause.&lt;/p&gt;
&lt;h2 id=&quot;compose-into-queryfn&quot;&gt;Compose into queryFn&lt;/h2&gt;
&lt;p&gt;So the transform moved again, into the &lt;code&gt;queryFn&lt;/code&gt;, through a small helper applied by the query option builders. Those builders are the per-domain factories that produce each query’s options, and both components and loaders use them. If a builder declares a transform, the helper wraps the fetch. The transformed shape is what lands in the cache, and everyone reads the same thing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; stays for what it is genuinely good at: deriving something view-local, like a component subscribing to one field of a bigger result.&lt;/p&gt;
&lt;p&gt;The cost is real. The raw response is gone from the cache, and devtools show the transformed shape. I think that trade is right here, because these transforms are canonical cleanup rather than presentation.&lt;/p&gt;
&lt;p&gt;The change was small because the builders already existed. Every domain in the dashboard produces its query options from one factory, so moving the transform meant changing where the factory applies it, not touching call sites. Had each query been assembled by hand, this would have been a migration instead of an edit.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Pick the transform point by listing who reads the cache, not by reaching for the documented default. If observers are the only readers, &lt;code&gt;select&lt;/code&gt; is correct. The moment loaders prefetch, the cache is a shared contract, and a contract belongs upstream of everyone who depends on it.&lt;/p&gt;</content:encoded><category>TanStack</category><category>React</category><category>Dashboard</category></item><item><title>Designing URLs for a Printed QR Code</title><link>https://vandre.dev/blog/designing-urls-for-a-printed-qr-code/</link><guid isPermaLink="true">https://vandre.dev/blog/designing-urls-for-a-printed-qr-code/</guid><description>A QR code printed in a book can never be reprinted. How that constraint shaped number-keyed routes, an owned domain, and QR codes pinned to a hardcoded origin.</description><pubDate>Sat, 04 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built the companion site for a book that is going to print. Every chapter carries a QR code linking to that chapter’s song.&lt;/p&gt;
&lt;p&gt;That one sentence contains the hardest constraint of the project. Once the book ships, the URL behind every code is frozen. You cannot patch paper. URL stability stops being a preference and becomes a contract with objects you will never be able to reach again.&lt;/p&gt;
&lt;h2 id=&quot;two-ways-a-url-dies&quot;&gt;Two ways a URL dies&lt;/h2&gt;
&lt;p&gt;The first threat is the host. The site deploys to a GitHub Pages project page under a repo path, which would tie every printed code to a vendor and a path prefix. The answer is a domain I own. If the host ever changes, that is one DNS update, and the printed codes never notice.&lt;/p&gt;
&lt;p&gt;The second threat is the path. The obvious route shape comes from the song title, and titles get edited. What does not change is the track number, so each song lives at &lt;code&gt;/t/01&lt;/code&gt; through &lt;code&gt;/t/10&lt;/code&gt;. Nothing anyone might rename appears in the path.&lt;/p&gt;
&lt;p&gt;Short paths help on paper too. Fewer characters means a sparser QR code, and a sparser code scans better on a smudged page.&lt;/p&gt;
&lt;h2 id=&quot;print-before-deploy&quot;&gt;Print before deploy&lt;/h2&gt;
&lt;p&gt;The subtle decision was in how the codes get generated. They are built at build time as SVGs, and they encode a hardcoded permanent origin rather than reading the framework’s site config.&lt;/p&gt;
&lt;p&gt;Reading the config would bake whatever it happened to say into every physical copy, and for most of the project it said a temporary GitHub Pages URL. Instead the codes point at the permanent domain from the start. They were correct on paper before that domain resolved at all, which felt wrong and was not. It means the book can go to print on its own schedule rather than waiting on the site.&lt;/p&gt;
&lt;p&gt;The codes use error correction level H, which survives roughly thirty percent damage. Paper smudges and books get carried around.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;A printed QR code is an API with one consumer you can never migrate. Design the URL the way you would design a public contract: nothing mutable in the path, no coupling to a host, and pin the target to the value that has to be true in five years rather than the one that is true today.&lt;/p&gt;</content:encoded><category>Architecture</category><category>Astro</category><category>Build</category></item><item><title>Rebuilding my personal site</title><link>https://vandre.dev/blog/rebuilding-my-portfolio/</link><guid isPermaLink="true">https://vandre.dev/blog/rebuilding-my-portfolio/</guid><description>A short tour of the latest rebuild: a static-first site with no client framework, reduced motion as a real signal, and most of the work shifted to build time.</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I rewrote my personal site. Every route had to score well on performance, accessibility, best practices and SEO, and the visual treatment was not allowed to get quieter to make that happen.&lt;/p&gt;
&lt;p&gt;What came out is a small static site with a few interactive pieces, an animated backdrop that turns itself off under reduced motion, and a design system page that documents itself.&lt;/p&gt;
&lt;h2 id=&quot;static-first-with-no-client-framework&quot;&gt;Static-first with no client framework&lt;/h2&gt;
&lt;p&gt;The site is mostly text with some decoration, so a static-first framework is the right shape. Every component renders to HTML by default, and the only JavaScript that ships is what a page explicitly mounts.&lt;/p&gt;
&lt;p&gt;An earlier version used a thin client framework to wrap the heavy pieces, a rotating globe and a WebGL backdrop. The wrapper was doing nothing. The real work lived in libraries that were not framework-specific, and the wrapper only mounted them. Removing it dropped about ten kilobytes gzipped of runtime that no UI was using.&lt;/p&gt;
&lt;p&gt;The interactive pieces now mount through plain script blocks, and each one gates itself. The globe waits until the hero scrolls into view. The backdrop checks &lt;code&gt;prefers-reduced-motion&lt;/code&gt; and only imports its implementation if motion is allowed. The question is not how fast something loads. It is whether it should load at all for this person.&lt;/p&gt;
&lt;h2 id=&quot;reduced-motion-as-a-real-signal&quot;&gt;Reduced motion as a real signal&lt;/h2&gt;
&lt;p&gt;The backdrop is heavy. For anyone with &lt;code&gt;prefers-reduced-motion&lt;/code&gt; set, none of it loads: the implementation sits behind a dynamic import gated on &lt;code&gt;matchMedia&lt;/code&gt;, so that group pays zero bytes for something they will never see. Every other animation on the site has the same branch, gated as high up as the asset graph allows.&lt;/p&gt;
&lt;h2 id=&quot;build-time-over-runtime&quot;&gt;Build-time over runtime&lt;/h2&gt;
&lt;p&gt;Open Graph images, structured metadata, the RSS feed, the sitemap, image variants and the audit that gates each deploy all happen at build time. No headless browser in the pipeline, no fonts installed at deploy, no cold start on the first request.&lt;/p&gt;
&lt;p&gt;There is also a &lt;code&gt;/system&lt;/code&gt; page listing every color, font and motion curve in use. It reads from the same token source as the rest of the site, so the documentation cannot drift from the implementation.&lt;/p&gt;
&lt;h2 id=&quot;a-page-for-the-music&quot;&gt;A page for the music&lt;/h2&gt;
&lt;p&gt;There is a &lt;code&gt;/music&lt;/code&gt; page with the tracks I keep nearby: what is on rotation, the all-time list, favorite mix compilations, and a separate cut of Brazilian songs from home. The data is a flat file and the rendering uses the same Section primitive as everything else.&lt;/p&gt;
&lt;h2 id=&quot;holding-the-line&quot;&gt;Holding the line&lt;/h2&gt;
&lt;p&gt;The constraint held. Every route still audits at ninety-nine or better on performance and full marks on accessibility, best practices and SEO, and the audit runs on every deploy rather than when I remember to check. The visual treatment did not get quieter to make that happen, which was the part I was most worried about giving up.&lt;/p&gt;
&lt;h2 id=&quot;what-i-cut&quot;&gt;What I cut&lt;/h2&gt;
&lt;p&gt;The client framework that wrapped canvas mounts and earned nothing. Hydration nobody would see. Shims for tools I do not use. Animations for people who asked not to have them. Convenience layers that pay off on a different shape of project.&lt;/p&gt;
&lt;p&gt;The site is small, builds in three seconds, and audits in under a minute.&lt;/p&gt;</content:encoded><category>Architecture</category><category>Performance</category><category>Accessibility</category></item><item><title>Audit Scripts for a Design System</title><link>https://vandre.dev/blog/audit-scripts-for-a-design-system/</link><guid isPermaLink="true">https://vandre.dev/blog/audit-scripts-for-a-design-system/</guid><description>How a style guide became a suite of audit scripts behind one umbrella command, grouped by what they protect, with parallel execution and an explicit drift-resolution workflow.</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A design system I work on had a style guide living in a contributor doc. It covered how to name stories, how to structure MDX pages, which imports were allowed, how to keep the public API stable, and how to use design tokens.&lt;/p&gt;
&lt;p&gt;Contributors had to remember all of it, and reviewers had to check it by hand. I turned the rules into a set of audit scripts behind one command. They now block any pull request that drifts.&lt;/p&gt;
&lt;h2 id=&quot;the-umbrella&quot;&gt;The umbrella&lt;/h2&gt;
&lt;p&gt;One command runs every audit at once. Each script pays a startup cost, and running them one after another adds all of those up. The runner starts them in parallel, buffers each one’s output, then prints the results in a fixed order. The log reads the same every time, whatever order the checks actually finished in.&lt;/p&gt;
&lt;p&gt;Each audit also runs on its own. The command takes a subcommand for a single check, plus flags for the ones that need a deliberate way to regenerate their baseline, like the API surface snapshot.&lt;/p&gt;
&lt;h2 id=&quot;the-categories&quot;&gt;The categories&lt;/h2&gt;
&lt;p&gt;Audits group by what they protect, not by what they run.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Story shape.&lt;/strong&gt; Every component ships its stories file and docs page in the expected shape. Compound components declare their sub-shapes in story meta, so the docs panel renders one props table per sub-shape. No prop spreading on components that count as stable surface.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Test coverage.&lt;/strong&gt; Every declared prop on an exported props type has at least one positive type test. Every stable component has a property test next to it. Interactive elements get keyboard exercise in their play function.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Public contract.&lt;/strong&gt; A generated snapshot of the public API diffs against the committed baseline. Every declared prop carries a JSDoc comment, so the docs panel shows descriptions and not just types.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Codebase hygiene.&lt;/strong&gt; No relative imports inside the design system. No hardcoded colors or pixel values in component CSS. Every stable component ships a Figma Code Connect file so it shows up in Figma’s dev-mode panel.&lt;/p&gt;
&lt;p&gt;The categories matter more than the individual scripts. A new audit goes into one of the same four buckets, so the list stays flat instead of growing branches.&lt;/p&gt;
&lt;h2 id=&quot;what-it-governs-now&quot;&gt;What it governs now&lt;/h2&gt;
&lt;p&gt;Forty-five checks run on every pull request, over sixty-eight components. Sixty of those components also ship a Figma Code Connect file, which is itself one of the checks: a component is not finished until it shows up in the designers’ dev-mode panel.&lt;/p&gt;
&lt;p&gt;That last one is the pattern worth copying. The rule does not describe a convention, it defines what finished means, and it applies to a component nobody has written yet.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;A general-purpose linter knows nothing about story exports, MDX structure, where property tests live, or Figma mappings. Each script is short because the rule it checks is short. Every one has the same shape: walk the design system, find violations, fail with the list. Adding a check is one new file and one new entry.&lt;/p&gt;
&lt;p&gt;The value is not in the scripts. It is in what reviewers stopped doing. Story shape, imports, token usage: nobody reads for those anymore. Contributors get feedback from CI in seconds instead of waiting for a review round, and new contributors learn the conventions from what the checks enforce rather than from whatever the doc happens to mention. Review is left for the parts that need judgment.&lt;/p&gt;</content:encoded><category>Design System</category><category>Tooling</category><category>CI</category></item><item><title>Test Layers for a Design System</title><link>https://vandre.dev/blog/test-layers-for-a-design-system/</link><guid isPermaLink="true">https://vandre.dev/blog/test-layers-for-a-design-system/</guid><description>Type tests, an API surface snapshot, story-level token-cascade guards, and property tests. Layered testing that catches the failure modes a design system actually has.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A design system has a different testing problem than an app. Its components rarely hold business logic. What they have instead is an API other teams build against, a token cascade that has to hold however someone composes them, and a prop space too large for anyone to enumerate by hand.&lt;/p&gt;
&lt;p&gt;So I built a stack of test layers, each one aimed at a different boundary.&lt;/p&gt;
&lt;h2 id=&quot;type-tests-catch-api-drift&quot;&gt;Type tests catch API drift&lt;/h2&gt;
&lt;p&gt;Every stable component ships a type-tests file next to it, covering props that should compile and props that should fail with a specific error.&lt;/p&gt;
&lt;p&gt;The negative cases turned out to be the more useful half. They lock in the guarantees. Someone who passes a renamed value gets a type error and a clear path forward, rather than a silent runtime fallback.&lt;/p&gt;
&lt;h2 id=&quot;an-api-surface-snapshot-tracks-the-public-shape&quot;&gt;An API surface snapshot tracks the public shape&lt;/h2&gt;
&lt;p&gt;A generated file lists every exported component, every prop and its type. CI regenerates it on each build and diffs it against the committed copy. If the surface moved, the diff lands in the failure message, so the reviewer sees exactly which prop was added, removed or retyped.&lt;/p&gt;
&lt;p&gt;That snapshot doubles as the source for tier promotion. A component cannot move up a tier until its surface is captured.&lt;/p&gt;
&lt;h2 id=&quot;story-level-guards-catch-broken-token-cascades&quot;&gt;Story-level guards catch broken token cascades&lt;/h2&gt;
&lt;p&gt;Stories run interaction tests through Storybook’s &lt;a href=&quot;https://storybook.js.org/docs/writing-stories/play-function&quot;&gt;play function&lt;/a&gt;. The interesting piece is a small helper that reads computed styles and checks each one resolved to a real value.&lt;/p&gt;
&lt;p&gt;Every design-token variable falls back to transparent. So when the cascade works, the property lands on a real color. When the chain breaks, because of a missing token, a typo, or an element rendered outside a theme scope, it lands on transparent and the test fails with the property name.&lt;/p&gt;
&lt;p&gt;The guard catches broken cascades. It does not catch hardcoded colors, which need a separate lint rule against literal color values in component CSS.&lt;/p&gt;
&lt;h2 id=&quot;property-tests-cover-the-prop-space&quot;&gt;Property tests cover the prop space&lt;/h2&gt;
&lt;p&gt;Another layer renders every stable component across its whole prop space: every union branch, every enum value, every boolean combination. If any combination throws, the framework shrinks it to the smallest failing case and prints the seed.&lt;/p&gt;
&lt;p&gt;The point is not that every combination looks right. It is that none of them throw. A component that breaks on a rare combination almost never shows up in a hand-written story, and property tests find it on the first run.&lt;/p&gt;
&lt;h2 id=&quot;what-it-covers&quot;&gt;What it covers&lt;/h2&gt;
&lt;p&gt;Sixty-eight components sit behind these layers, with a hundred and ten test files and seventy-two type-test files between them. The counts matter less than the fact that promotion is gated on them: a component cannot move up a tier until its surface is captured and its property tests pass.&lt;/p&gt;
&lt;p&gt;That is the part I would keep. Not the layers themselves, but tying them to promotion, so the tests are a precondition for calling something stable rather than a chore to catch up on afterwards.&lt;/p&gt;
&lt;h2 id=&quot;what-this-catches-that-unit-tests-do-not&quot;&gt;What this catches that unit tests do not&lt;/h2&gt;
&lt;p&gt;Unit tests check what a component does. These layers check what it promises: a stable type contract, an enumerated public API, tokens that actually cascade, and a prop space that never throws.&lt;/p&gt;</content:encoded><category>Design System</category><category>Testing</category><category>TypeScript</category></item><item><title>Caching Sharp-Processed Images in Static Builds</title><link>https://vandre.dev/blog/caching-sharp-image-processing/</link><guid isPermaLink="true">https://vandre.dev/blog/caching-sharp-image-processing/</guid><description>How I cut documentation build times by hashing image inputs, committing the preprocessed webp outputs, and skipping image work on every CI run.</description><pubDate>Sun, 01 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A &lt;a href=&quot;https://getstream.io/chat/docs/react/&quot;&gt;documentation site&lt;/a&gt; I worked on processed thousands of images on every build. &lt;a href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;Sharp&lt;/a&gt; converted each one to webp, measured it, and emitted several sizes. The source images almost never changed, so the work was the same every time, and CI redid all of it from scratch. The build times showed it.&lt;/p&gt;
&lt;p&gt;The site carries about fifteen hundred images across more than five thousand pages, and CI rebuilt every one of them on every run.&lt;/p&gt;
&lt;h2 id=&quot;the-cache-boundary&quot;&gt;The cache boundary&lt;/h2&gt;
&lt;p&gt;My first instinct was a runtime cache. That helped locally and did nothing for CI, where every run starts cold. What I actually needed was for the processed images to survive between builds, so I moved the boundary: the webp outputs and their metadata went into the repo, committed under a generated folder.&lt;/p&gt;
&lt;p&gt;Now the build asks a simpler question. Do I already have a processed version of this image? If yes, skip Sharp and copy the cached file. If no, process it and add it.&lt;/p&gt;
&lt;h2 id=&quot;detecting-real-changes&quot;&gt;Detecting real changes&lt;/h2&gt;
&lt;p&gt;Treating every touched file as a miss would defeat the point. I needed a hash that captured what actually changes the output.&lt;/p&gt;
&lt;p&gt;Modified time was the first thing to throw out. Git rewrites mtime on checkout, on rebase, on anything that touches a file’s index entry, so a cache keyed on it invalidates every time someone pulls. An early version made exactly that mistake. The fix was to ignore mtime and key on path plus size instead.&lt;/p&gt;
&lt;p&gt;Two files with the same path and size produce the same result, so they get the same hash. There is a small collision risk, two different images at the same path with the same byte length, and I accepted it: a bad hit gives you a wrong image, not a broken build, and the fix is to bump a version field and regenerate rather than debug a crash.&lt;/p&gt;
&lt;p&gt;Hashing more would have been safer in theory, but every extra field is another way to invalidate the cache by accident. So the rule was to hash only the inputs whose change would alter the output. Source contents through size, the processing parameters, and the Sharp major version. Nothing else.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; cacheKey&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;src&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; string&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;stat&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; Stats&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;params&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; ProcessParams&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;  createHash&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;sha256&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    .&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;update&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;      JSON&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;stringify&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        path: &lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;relative&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(root, src),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        size: stat.size,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        params,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        sharpMajor: sharpVersion.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;split&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;.&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)[&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        revision: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;      }),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    .&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;digest&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;hex&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    .&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;slice&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;16&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-ci-side&quot;&gt;The CI side&lt;/h2&gt;
&lt;p&gt;CI sets an environment variable. When the build sees it, the image step short-circuits and trusts the committed cache. New or changed images get processed in their own pull request, where the cache update lands in the diff and reviewers see the new webp next to the source.&lt;/p&gt;
&lt;p&gt;Image work went from minutes to near zero on most runs. Sharp only wakes up when an image actually changed, which is exactly when you want it to.&lt;/p&gt;</content:encoded><category>Build</category><category>Performance</category><category>Astro</category></item><item><title>Replacing Astro Image for a Documentation Site</title><link>https://vandre.dev/blog/replacing-astro-image-with-a-custom-wrapper/</link><guid isPermaLink="true">https://vandre.dev/blog/replacing-astro-image-with-a-custom-wrapper/</guid><description>Why I removed Astro&apos;s built-in Image component from a docs site and replaced it with a thin custom wrapper, and the lesson about when generic abstractions cost more than they save.</description><pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I started a &lt;a href=&quot;https://getstream.io/chat/docs/react/&quot;&gt;docs site&lt;/a&gt; with &lt;a href=&quot;https://docs.astro.build/en/guides/images/&quot;&gt;Astro’s &lt;code&gt;Image&lt;/code&gt; component&lt;/a&gt;, because the defaults are good and the Sharp integration is already wired. After a few months at scale, I took it out.&lt;/p&gt;
&lt;p&gt;The problems were not really Astro’s fault. They came from an abstraction that has to serve every kind of content site being applied to one with very specific needs. Working out the size of remote images was the slowest step in the build. Tables with images in them fought the wrapper’s layout. Adding width and height back so table cells rendered correctly became a recurring fix. The wrapper’s padding and aspect-ratio behavior made sense for a blog hero and no sense at all for a small inline diagram.&lt;/p&gt;
&lt;p&gt;The scale is what turned an annoyance into a decision. Around fifteen hundred images sit across more than five thousand pages, and they are not one kind of image. Hero shots, screenshots pulled from remote URLs, diagrams inside table cells, and dozens of tiny inline icons all went through the same component with the same assumptions.&lt;/p&gt;
&lt;h2 id=&quot;the-custom-wrapper&quot;&gt;The custom wrapper&lt;/h2&gt;
&lt;p&gt;The replacement takes a source path, renders an image tag, and gets out of the way. Local images get their dimensions from Sharp’s metadata function, called once at build. Remote images skip the inference entirely and let the browser handle it, with explicit loading, decoding and fetch priority set so above-the-fold images still arrive fast.&lt;/p&gt;
&lt;p&gt;The whole component ended up smaller than the configuration block I had been writing for Astro Image. It covers the cases the docs actually have and stops covering the ones they do not.&lt;/p&gt;
&lt;h2 id=&quot;when-generic-costs-more-than-specific&quot;&gt;When generic costs more than specific&lt;/h2&gt;
&lt;p&gt;Every shared abstraction has a point where it stops paying for itself. The signal is that you start fighting it: overrides, workarounds, conditional imports. Each override is the abstraction telling you it does not fit. After the third or fourth, writing the small specific version is usually cheaper.&lt;/p&gt;
&lt;p&gt;I am not against Astro Image. For a content site with a uniform shape it is the right default. For a docs site with mixed content, diagrams inside tables, remote screenshots and dozens of inline icons, bending it cost more than replacing it.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The strength of an abstraction is that it handles many cases at once. The weakness is that it handles them all the same way. When your content varies more than the abstraction expects, stop fighting it and write the small thing that fits.&lt;/p&gt;</content:encoded><category>Astro</category><category>Performance</category><category>Images</category></item><item><title>Custom markdownlint Rules for Docs Conventions</title><link>https://vandre.dev/blog/custom-markdownlint-rules-for-docs/</link><guid isPermaLink="true">https://vandre.dev/blog/custom-markdownlint-rules-for-docs/</guid><description>How I encoded the docs site&apos;s actual conventions into project-specific markdownlint rules so contributors stopped having to remember them.</description><pubDate>Mon, 15 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A &lt;a href=&quot;https://getstream.io/chat/docs/react/&quot;&gt;docs site&lt;/a&gt; I worked on had conventions that mattered to us and to nobody else. Every page needed a slug and a title in its frontmatter. Code tabs needed a blank line above and below. Some old URL shapes were no longer allowed. Custom admonition components had to be written a particular way. TODO markers were not supposed to reach published pages.&lt;/p&gt;
&lt;p&gt;None of this is anything off-the-shelf markdownlint cares about. All of it was written down in a contributor doc, and all of it was enforced by whoever happened to be reviewing.&lt;/p&gt;
&lt;h2 id=&quot;rules-that-encode-project-decisions&quot;&gt;Rules that encode project decisions&lt;/h2&gt;
&lt;p&gt;I added a small set of project-specific rules to the markdownlint config. Each one captured a decision the team had already made, in a form CI could check.&lt;/p&gt;
&lt;p&gt;The frontmatter rule failed any file missing a slug or title. The link rule blocked URL patterns we had moved away from. The admonition rule required our custom syntax to open and close the way the renderer expected. The codetabs rule required blank lines around the wrapper so the parser resolved it. The TODO rule caught notes writers left themselves and forgot to remove.&lt;/p&gt;
&lt;p&gt;Each rule was tiny. Together they turned the things we cared about into something a machine checks, which meant people stopped having to remember them.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;module&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;exports&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  names: [&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;docs-no-todo&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  description: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;TODO markers must not reach published content&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  tags: [&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;docs&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;  function&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: (&lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;params&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;onError&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    params.lines.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;forEach&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;line&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;index&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;      const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; marker&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;\b&lt;/span&gt;&lt;span style=&quot;color:#DBEDFF&quot;&gt;(TODO&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#DBEDFF&quot;&gt;FIXME)&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;\b&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;exec&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(line);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;      if&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;marker) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;      onError&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        lineNumber: index &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        detail: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;`Found ${&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;marker&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;}`&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        context: line.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;slice&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;60&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;      });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-scale-that-makes-it-worth-it&quot;&gt;The scale that makes it worth it&lt;/h2&gt;
&lt;p&gt;Seven rules, over five thousand pages, and more than a hundred people who have committed to them. That ratio is the whole argument. A convention explained in a contributor doc has to be read by every one of those people and remembered by all of them at once. A rule has to be written once.&lt;/p&gt;
&lt;p&gt;The people who benefit most are the ones who never read the doc, which is most of them, and who now find out in CI in seconds rather than in a review comment a day later.&lt;/p&gt;
&lt;h2 id=&quot;the-allowlist-conversation&quot;&gt;The allowlist conversation&lt;/h2&gt;
&lt;p&gt;The hardest rule was the one that blocked certain image paths but allowed aliased imports. Real projects always have exceptions, and the trick is not to pretend otherwise. The rule reads an explicit allowlist, and adding an entry is a small pull request with the path and a one-line reason. A reviewer sees the exception and accepts it on purpose, instead of the linter quietly being lenient.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;A linter is not the place for clever rules. It is the place for boring ones: the conventions that came out of a conversation you have already had three times. Writing them down as rules is how you avoid having it a fourth time.&lt;/p&gt;
&lt;p&gt;The saving compounds. Every new contributor gets the conventions for free, and reviewers spend their attention on the things only a person can catch.&lt;/p&gt;</content:encoded><category>Documentation</category><category>Tooling</category><category>Linting</category></item><item><title>Building an OpenAPI Code Generation Pipeline with Kubb</title><link>https://vandre.dev/blog/openapi-codegen-pipeline-with-kubb/</link><guid isPermaLink="true">https://vandre.dev/blog/openapi-codegen-pipeline-with-kubb/</guid><description>How I automated TypeScript type and Zod schema generation from OpenAPI specs using Kubb, with scheduled CI runs and team notifications for breaking changes.</description><pubDate>Wed, 12 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The dashboard I worked on consumed several APIs, all with OpenAPI specs, all changing often. Keeping hand-written TypeScript types in step was not sustainable, so I built a pipeline to generate them.&lt;/p&gt;
&lt;p&gt;This is one piece of the dashboard’s integration layer. The pipeline keeps generated code in step with upstream specs, and it sits downstream of the contract-first mocks and the decision to bet on OpenAPI in the first place.&lt;/p&gt;
&lt;h2 id=&quot;why-kubb&quot;&gt;Why Kubb&lt;/h2&gt;
&lt;p&gt;OpenAPI codegen tools differ on two things that matter. What they generate: types only, types plus runtime validators, or a whole SDK. And how you customize the output: not at all, through templates, or through plugins.&lt;/p&gt;
&lt;p&gt;A types-only generator produces clean output but leaves runtime validation as a separate thing to maintain, which is the same synchronization problem the codegen was meant to solve. A full-SDK generator covers everything but tends to emit code that does not read like code a person wrote, and engineers route around code they cannot follow the moment they hit an edge case.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://kubb.dev/&quot;&gt;Kubb&lt;/a&gt; sits between the two. It generates TypeScript types and Zod schemas from the same spec in one pass, so the validator and the type always come from the same source. The plugin system mattered just as much: I could shape the output to match our naming conventions, so the generated client read like something handwritten. When generated code feels native, nobody wants to fork it.&lt;/p&gt;
&lt;h2 id=&quot;preprocessing-the-specs&quot;&gt;Preprocessing the specs&lt;/h2&gt;
&lt;p&gt;The specs needed cleaning before generation. Upstream services emitted documents that were valid but awkward: naming conventions varied between teams, optional and required fields drifted, and response envelopes wrapped payloads differently from one service to the next. None of it was wrong. It is just what happens when each team owns its own spec and the consumers converge them later.&lt;/p&gt;
&lt;p&gt;So a preprocessing step ran before Kubb and normalized the input every time. The output stayed stable regardless of upstream wording, which mattered downstream: snapshot diffs then only flagged real contract changes instead of cosmetic ones that flipped with every release.&lt;/p&gt;
&lt;h2 id=&quot;the-automated-pipeline&quot;&gt;The automated pipeline&lt;/h2&gt;
&lt;p&gt;A CI workflow runs on a schedule. It fetches the latest specs, normalizes them, runs Kubb, type-checks the result, and diffs it against the committed snapshot.&lt;/p&gt;
&lt;p&gt;The diff is where the pipeline earns its keep. Three things can show up. Added endpoints are mostly safe, though worth a look in case the addition signals a deprecation somewhere else. Removed endpoints break consumers and block the merge until they are updated. Changed shapes are the dangerous ones: same name, different fields, the failure that used to bite us at runtime.&lt;/p&gt;
&lt;p&gt;When the diff finds something, the workflow posts a summary to a shared channel naming the endpoints, classifying each change, and linking the spec revision. Engineers see it at the boundary where it happened, rather than when a build starts failing for no obvious reason.&lt;/p&gt;
&lt;h2 id=&quot;what-it-regenerates&quot;&gt;What it regenerates&lt;/h2&gt;
&lt;p&gt;The pipeline now owns thousands of generated schemas and types across several upstream specs. Nobody edits any of it. When a spec moves, the diff shows up in a channel before a consumer notices, and the regeneration is a pull request somebody reviews rather than a migration somebody schedules.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;What the pipeline really changed was where breaking changes got noticed. Before, a removed endpoint surfaced as a failing build in whichever consumer touched it first, days after the spec moved, with the cause several steps away from the symptom. Now it surfaces in the scheduled diff, named and classified, before anyone writes code against the old shape.&lt;/p&gt;
&lt;p&gt;Codegen only pays back as fast as you regenerate. If the spec changes weekly and regeneration runs monthly, you have bought a delayed integration bug rather than prevented one. The scheduled runs are what made the bet worth taking.&lt;/p&gt;
&lt;p&gt;The deeper lesson sits above the tooling. Choosing a tool is downstream of choosing a source of truth. Once the spec is the source, any tool that respects it will do, and the choice comes down to how the output feels to work with. Kubb fit this dashboard. Another project might land somewhere else.&lt;/p&gt;</content:encoded><category>OpenAPI</category><category>Kubb</category><category>Codegen</category><category>Integration Layer</category></item><item><title>Building Type-Safe APIs with OpenAPI</title><link>https://vandre.dev/blog/building-type-safe-apis-with-openapi/</link><guid isPermaLink="true">https://vandre.dev/blog/building-type-safe-apis-with-openapi/</guid><description>How the dashboard API integration layer became its own product: OpenAPI as the single source of truth across multiple consuming teams.</description><pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The dashboard I worked on already had OpenAPI specs, but nothing was generated from them. Every API change meant somebody updated TypeScript types by hand, and integration bugs kept reaching production.&lt;/p&gt;
&lt;p&gt;When several teams ship into the same dashboard against the same upstream APIs, that integration layer is its own product. It has consumers and a public surface. So the real question was whether to invest in it, or let each team keep negotiating API consumption on its own.&lt;/p&gt;
&lt;p&gt;We went all in on OpenAPI as the single source of truth: types, clients and validators, all generated from the spec. The frontend stopped defining its own API types.&lt;/p&gt;
&lt;p&gt;Three other pieces sit alongside this. Contract-first mocks ship the shape before the implementation. A proxy layer puts two upstream APIs behind one surface. A CI pipeline regenerates the code and flags breaking changes. This post is about the generation decision itself.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The pipeline&lt;/h2&gt;
&lt;p&gt;A codegen step runs on every spec change and emits TypeScript types, thin client functions, and request and response validators. The first version used &lt;code&gt;openapi-typescript&lt;/code&gt; for types with custom templates for the rest. As the validators grew, the pipeline moved to a tool that handles types and Zod schemas in one pass. That migration is its own story.&lt;/p&gt;
&lt;p&gt;CI watches the spec. When it changes upstream, regeneration opens a pull request, so reviewers see exactly what moved in the contract.&lt;/p&gt;
&lt;h2 id=&quot;alternatives-considered&quot;&gt;Alternatives considered&lt;/h2&gt;
&lt;p&gt;This bet meant rejecting three other approaches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hand-written types, per consumer.&lt;/strong&gt; The status quo. Every team maintained types for the endpoints it called, and those types drifted from reality without anyone noticing. That is what prompted the work. The cost was paid every release rather than once.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The spec as documentation only.&lt;/strong&gt; Keep the spec as the agreed contract on paper and keep hand-writing everything that consumes it. The spec never gains teeth. Drift shows up at runtime instead of at build time, and the spec stays a document rather than something the code depends on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generate the types and nothing else.&lt;/strong&gt; A narrower bet: generate types, hand-write clients, validators and request shapes. It leaves the spec out of the parts that matter most at runtime, where validation and errors live, and it still leaves two sources of truth to keep in step.&lt;/p&gt;
&lt;p&gt;The full pipeline costs more up front and pays back the moment the spec changes. Every alternative has a case where the spec drifts from reality and nothing pulls it back.&lt;/p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What changed&lt;/h2&gt;
&lt;p&gt;Integration errors dropped to near zero. Code reviews stopped including manual type-versus-API checks. Refactoring API code got safer, because the compiler caught contract violations that used to surface in production.&lt;/p&gt;
&lt;p&gt;The biggest practical win was confidence. Engineers stopped guessing whether the types matched the response.&lt;/p&gt;
&lt;h2 id=&quot;what-it-grew-into&quot;&gt;What it grew into&lt;/h2&gt;
&lt;p&gt;The generated surface is now the whole API layer of the dashboard: thousands of validators and types, feeding a hundred query-option modules, consumed by every route in the application. Twenty-odd people have shipped into that dashboard since, and none of them has written an API type by hand.&lt;/p&gt;
&lt;p&gt;That is the part that made it worth the up-front cost. Not that the types are correct, but that writing one yourself stopped being an option anybody reaches for.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Codegen is not a silver bullet. The generated code has to be readable, because engineers route around output they cannot follow. I spent real time tuning the templates until the client read like something a person had written.&lt;/p&gt;
&lt;p&gt;The other lesson is to treat the spec as a first-class artifact. If the spec is wrong, the generated code is wrong too. Validating it in CI fails the build before a bad spec reaches anyone.&lt;/p&gt;</content:encoded><category>OpenAPI</category><category>TypeScript</category><category>API</category><category>Integration Layer</category></item><item><title>A Practical Prefetching Strategy with TanStack Query and Router</title><link>https://vandre.dev/blog/tanstack-query-prefetching-strategy/</link><guid isPermaLink="true">https://vandre.dev/blog/tanstack-query-prefetching-strategy/</guid><description>How I implemented intent-based preloading, route-level prefetching, and pagination prefetching to make a dashboard feel instant.</description><pubDate>Fri, 29 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A dashboard that feels slow loses trust. People click a link and expect the data to already be there. I built a prefetching strategy that makes a dashboard feel instant even when the API takes a few hundred milliseconds.&lt;/p&gt;
&lt;p&gt;It works in two layers: prefetching at the route level in loaders, and prefetching the rows someone is most likely to click.&lt;/p&gt;
&lt;h2 id=&quot;route-level-prefetching&quot;&gt;Route-level prefetching&lt;/h2&gt;
&lt;p&gt;I moved prefetching from &lt;code&gt;beforeLoad&lt;/code&gt; into the &lt;code&gt;loader&lt;/code&gt; on every route. The loader calls &lt;code&gt;queryClient.ensureQueryData()&lt;/code&gt; with the same query options the page components use, so fetching starts when navigation starts rather than when a component mounts.&lt;/p&gt;
&lt;p&gt;The decision that made this work was using the same query options factories in both places. I pulled them into per-domain files so the loader and the component could never disagree about a cache key or a stale time. By the time the page mounted, the data was already there.&lt;/p&gt;
&lt;p&gt;I tried &lt;code&gt;defaultPreload: &apos;intent&apos;&lt;/code&gt; for hover-based prefetching at the router level and turned it off. With loaders already running on navigation and most lists being long, hover preload was either redundant for the row we were about to click or wasted on the dozens we were not. The loaders gave the same perceived speed without firing a request every time the pointer moved.&lt;/p&gt;
&lt;h2 id=&quot;prefetching-what-is-likely-next&quot;&gt;Prefetching what is likely next&lt;/h2&gt;
&lt;p&gt;For tables I added an intersection observer on the rows. When a row scrolls into view, it kicks off a background &lt;code&gt;prefetchQuery&lt;/code&gt; for that row’s detail view, so the data is usually cached before the click.&lt;/p&gt;
&lt;p&gt;The observer runs with a small positive &lt;code&gt;rootMargin&lt;/code&gt;, so prefetching starts just before a row is fully visible. That buys the network a few hundred milliseconds on a slow connection. The threshold stays at the default, because partial visibility is signal enough. A &lt;code&gt;Set&lt;/code&gt; tracks which rows have already been prefetched, so scrolling up and down does not refire anything.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; prefetched&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; Set&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; observer&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; IntersectionObserver&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  (&lt;/span&gt;&lt;span style=&quot;color:#FFAB70&quot;&gt;entries&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; entry&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; entries) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;      const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; id&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (entry.target &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;as&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; HTMLElement&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;).dataset.rowId;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;      if&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;entry.isIntersecting &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;id &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; prefetched.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;has&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(id)) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;continue&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;      prefetched.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;add&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(id);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;      void&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; queryClient.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;prefetchQuery&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(invoiceQueries.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;detail&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(id));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  { rootMargin: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;200px 0px&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This beat prefetching whole pages. Most people do not read a table in order. They scan, scroll, and click the one or two rows that catch their eye, so prefetching exactly those rows is cheaper and more accurate than fetching the next page in either direction. It also fails gracefully: scroll too fast for the observer and the detail view falls back to the same loader fetch everything else uses.&lt;/p&gt;
&lt;h2 id=&quot;why-it-stuck&quot;&gt;Why it stuck&lt;/h2&gt;
&lt;p&gt;The reason this outlived the feature it was built for is that it needs no per-route decision. Every route in the dashboard loads the same way, because the loader and the component read the same query-option factory. A new route inherits the behavior by using the factory, and nobody re-argues prefetching page by page.&lt;/p&gt;
&lt;p&gt;That is the difference between a technique and a default. A technique gets applied where somebody remembers it. A default applies where nobody thinks about it.&lt;/p&gt;
&lt;h2 id=&quot;what-i-turned-off&quot;&gt;What I turned off&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;refetchOnWindowFocus&lt;/code&gt;, globally. Stale times are already tuned to how often each kind of data changes, so a focus event is the wrong trigger. Data that should refresh will refresh on its own boundary, and data that should not is exactly what people saw reloading every time they came back to the tab. Turning it off removed that jarring full-page refresh.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;refetchOnReconnect&lt;/code&gt; went for the same reason. The refresh strategy lives in stale times, not in event triggers.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;retry&lt;/code&gt; is a small fixed number for mutations and zero for queries that come back 4xx. A 401 or a 404 is not a transient failure, and retrying it only delays the error someone needs to see.&lt;/p&gt;</content:encoded><category>TanStack</category><category>Performance</category><category>React</category></item><item><title>Encoding Pricing Rules Without Forking the UI</title><link>https://vandre.dev/blog/encoding-pricing-rules-as-data/</link><guid isPermaLink="true">https://vandre.dev/blog/encoding-pricing-rules-as-data/</guid><description>How I moved pricing math out of components and into the plan definition so a marketing site could absorb new products and billing models without UI changes.</description><pubDate>Tue, 15 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A marketing site I worked on priced several products, and no two priced the same way. One charged by usage across a few dimensions, with multipliers. One was tiered by stored data and scaled by monthly active users. One mixed time-based units, storage tiers, and addons that applied to some plans and not others. New products kept arriving with new billing models.&lt;/p&gt;
&lt;p&gt;The UI was fine. An earlier round of work had already solved the sliders, the comparison tables, the plan cards and the mobile grids. What kept breaking was the math behind them. Every new billing rule tempted someone to add another conditional inside a component, and two products in, that was already going badly.&lt;/p&gt;
&lt;h2 id=&quot;plans-as-data&quot;&gt;Plans as data&lt;/h2&gt;
&lt;p&gt;I moved the rules out of the components and into the plan definition. Each plan declared what kind of calculation it used and what inputs it took. The calculations themselves lived in shared functions, one per billing model. The component got generic: read the plan, render the inputs it asks for, call the right function.&lt;/p&gt;
&lt;p&gt;Adding a billing dimension stopped being a UI change. A new tier, a new multiplier, an addon that only applies above some threshold: each one is a small addition to the schema and maybe a new function. The components never notice.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; const&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; storagePlan&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  model: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;tiered&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  inputs: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    { key: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;storedGb&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, label: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;Stored data&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, max: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;5_000&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    { key: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;mau&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, label: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;Monthly active users&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, max: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;100_000&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  tiers: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    { upTo: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;100&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, unitPrice: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    { upTo: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1_000&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, unitPrice: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0.12&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    { upTo: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Infinity&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, unitPrice: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0.08&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  addons: [{ key: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&apos;sso&apos;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, price: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;99&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, requiresTier: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; }],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;} &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;satisfies&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; PlanDefinition&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;four-products-later&quot;&gt;Four products later&lt;/h2&gt;
&lt;p&gt;Chat, video, activity feeds and moderation all render from the same component now. Each keeps its own folder of plan data: the cards, the comparison rows, the prices, the feature lists. None of them knows the others exist.&lt;/p&gt;
&lt;p&gt;A product that prices unusually does not fork the renderer. It adds a small slice that overrides the part that differs and inherits the rest. Moderation needed its own cards. Feeds needed its own overage tables. Both are additions sitting next to the shared renderer rather than edits to it.&lt;/p&gt;
&lt;p&gt;The real test of the boundary is what a new product line costs. It is a folder of data and, at most, a small slice. Nobody opens the component that draws the page.&lt;/p&gt;
&lt;h2 id=&quot;the-legacy-corner&quot;&gt;The legacy corner&lt;/h2&gt;
&lt;p&gt;Pricing carries history. Customers on older plans still need to see what they signed up under, even after the public rules move on. The schema handles those as named legacy plans pointing at frozen rule sets. New products launch in the main schema and legacy plans live in their own corner of it.&lt;/p&gt;
&lt;p&gt;It is not the cleanest separation. It follows the same rule though: the plan owns its pricing, never the component.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Rules inside components grow by branching. Rules inside data grow by extension. The first time you catch yourself checking a plan slug inside a pricing component, the schema is wrong.&lt;/p&gt;
&lt;p&gt;Once that line is drawn, a new product is a schema entry and nothing else.&lt;/p&gt;</content:encoded><category>Architecture</category><category>Marketing</category><category>Patterns</category></item><item><title>Mocking the API Contract Before Implementing It</title><link>https://vandre.dev/blog/mocking-api-contracts-before-implementing/</link><guid isPermaLink="true">https://vandre.dev/blog/mocking-api-contracts-before-implementing/</guid><description>How shipping the API contract first, a live endpoint serving a realistic OpenAPI payload, removed the synchronization tax between backend and frontend.</description><pubDate>Sun, 01 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When backend and frontend build the same feature, the frontend waits. Everyone agreed the API shape on a whiteboard, but until the endpoint returns something, nobody can wire the UI to it. So the frontend writes mocks, the mocks drift, and “we agreed on the shape” stops meaning much once the real thing lands looking slightly different.&lt;/p&gt;
&lt;p&gt;I started shipping the contract before the implementation. The endpoint goes to production early, returns a realistic example payload, and serves the agreed OpenAPI shape before any real work happens behind it.&lt;/p&gt;
&lt;p&gt;This is one piece of the dashboard’s integration layer. Contract-first mocks sit at the boundary between teams, before the codegen pipeline or the proxy layer ever see the spec.&lt;/p&gt;
&lt;h2 id=&quot;the-contract-is-the-decision&quot;&gt;The contract is the decision&lt;/h2&gt;
&lt;p&gt;The shape of an API is the part worth arguing about: field names, types, what is optional, how a list differs from a single item, how errors come back. Once those are settled, the implementation behind them is just engineering. The implementation can change later. The contract is what the frontend, the SDK consumers, the docs and the SLA all depend on.&lt;/p&gt;
&lt;p&gt;Treating the contract as the output of the design phase changes the order of work. Design, build, expose becomes design, expose, build.&lt;/p&gt;
&lt;h2 id=&quot;alternatives-considered&quot;&gt;Alternatives considered&lt;/h2&gt;
&lt;p&gt;This meant rejecting three patterns the team had already tried.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Wait for the backend.&lt;/strong&gt; The default. Frontend work blocks on backend work even when everyone agrees on the contract. That delay is the whole problem, and being patient about it does not make it smaller.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Frontend-local stubs.&lt;/strong&gt; Everyone writes their own mock for whatever they need to render today. The stubs drift from each other and from reality, and by the time the real endpoint lands there are three code paths for the same data to reconcile.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Types only.&lt;/strong&gt; Publish the TypeScript types from the spec and leave the endpoint for later. You can compile against types, but they do not return data. QA, integration tests and screenshots all still wait.&lt;/p&gt;
&lt;p&gt;A mocked endpoint gives you what each of those was reaching for at once: a real URL, real types and realistic payloads, in production from day one.&lt;/p&gt;
&lt;h2 id=&quot;realistic-examples-beat-empty-stubs&quot;&gt;Realistic examples beat empty stubs&lt;/h2&gt;
&lt;p&gt;An endpoint returning an empty list is technically a contract and practically useless. The frontend cannot tell whether the rendering is right, because the data does not look like data. QA cannot test edge cases, because there are none.&lt;/p&gt;
&lt;p&gt;An endpoint returning a realistic payload is a different thing. It looks like what you will actually get. Schema generators consume it and docs tools render it as a sample response. The same examples end up in the spec, the mock and the documentation, from one source.&lt;/p&gt;
&lt;h2 id=&quot;a-marker-for-what-is-real&quot;&gt;A marker for what is real&lt;/h2&gt;
&lt;p&gt;The catch is that a consumer cannot tell a real endpoint from a stub returning canned data. So each endpoint carried a small marker showing its implementation status. Mocked ones appeared as mocked in internal dashboards, and CI refused to call an endpoint done until the marker came off.&lt;/p&gt;
&lt;p&gt;It did one boring job well. It made the difference between “returns 200” and “does what it says” visible to everyone.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The delay between backend and frontend is real but mostly self-imposed. Ship the contract first, even as a mock, and the problem turns into scheduling. The frontend builds against the real URL, the backend implements at its own pace, and the day the implementation lands the frontend already works.&lt;/p&gt;</content:encoded><category>API</category><category>Architecture</category><category>DX</category><category>Integration Layer</category></item><item><title>Proxy Patterns for Multi-API Dashboards</title><link>https://vandre.dev/blog/proxy-patterns-for-multi-api-dashboards/</link><guid isPermaLink="true">https://vandre.dev/blog/proxy-patterns-for-multi-api-dashboards/</guid><description>How I designed a proxy layer to unify multiple APIs with different auth, error shapes, and data models behind one type-safe surface in a React dashboard.</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The dashboard I worked on talked to two APIs with different authentication, different error shapes and different data models. That leaves you with a choice. Either every component learns both, or one layer learns both and the rest of the code stops noticing.&lt;/p&gt;
&lt;p&gt;The first option gets worse with every endpoint you add. The second only gets worse when the two APIs differ, and they differ in a handful of fixed ways. I built the layer.&lt;/p&gt;
&lt;p&gt;This is one piece of the dashboard’s integration layer. It is what consumer code actually sees: one typed surface, fed by contract-first mocks during design and by the codegen pipeline once specs ship.&lt;/p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;/h2&gt;
&lt;p&gt;One API handled platform concerns: organizations, users, billing. The other handled product features, and it was only reachable through the platform API’s proxy endpoint, which added its own routing rules.&lt;/p&gt;
&lt;p&gt;So every component that fetched data had to know which API it wanted, how to authenticate against it, and how to read its errors. That knowledge was copied all over the codebase.&lt;/p&gt;
&lt;h2 id=&quot;the-solution&quot;&gt;The solution&lt;/h2&gt;
&lt;p&gt;I split data fetching into two families of hooks, each with its own fetch wrapper, error handling and types. The management hooks authenticate directly. The product hooks route through the proxy and inject the context it needs.&lt;/p&gt;
&lt;p&gt;Both families share one query options builder for cache keys, stale times and retry rules. The type signatures stay separate, so a management endpoint cannot be passed to a product hook by mistake. The compiler catches it.&lt;/p&gt;
&lt;p&gt;The piece I liked most was making the product hooks decide for themselves whether to run. If the context they need is not there yet, the query disables itself instead of firing and failing. That removed a whole class of race conditions during navigation.&lt;/p&gt;
&lt;h2 id=&quot;query-options-as-a-shared-language&quot;&gt;Query options as a shared language&lt;/h2&gt;
&lt;p&gt;Each domain has an options file exporting functions that return &lt;code&gt;queryOptions&lt;/code&gt;. The same options feed the hooks in components and the route loaders that prefetch.&lt;/p&gt;
&lt;p&gt;One configuration drives both. Change how a query is cached and both the component fetch and the loader pick it up.&lt;/p&gt;
&lt;h2 id=&quot;what-it-looks-like-at-size&quot;&gt;What it looks like at size&lt;/h2&gt;
&lt;p&gt;Every domain in the dashboard now has its own options module, around a hundred of them, and every route in the application pulls from that set. The two hook families still hold the only knowledge of which API is which and how each one authenticates.&lt;/p&gt;
&lt;p&gt;The measure of whether the boundary worked is that the number of places knowing about the proxy never grew. New features arrived, routes multiplied, and the routing rules stayed in one layer.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The proxy layer moved complexity out of every component and into one place. The rest of the codebase stopped needing to know anything about routing or authentication. Engineers pick the right hook, and the type system handles the rest.&lt;/p&gt;</content:encoded><category>Architecture</category><category>API</category><category>Dashboard</category><category>Integration Layer</category></item><item><title>Hybrid Next.js: Static Export and SSR from One Codebase</title><link>https://vandre.dev/blog/hybrid-nextjs-static-export-and-ssr/</link><guid isPermaLink="true">https://vandre.dev/blog/hybrid-nextjs-static-export-and-ssr/</guid><description>How I shipped a Next.js marketing site that builds as a static export for one host and as a server-rendered app on Vercel, without forking the codebase.</description><pubDate>Fri, 15 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A marketing site I worked on had to deploy two ways. One target wanted a fully static export: no servers, no API routes, just files. The other was Vercel, where preview deploys needed real server rendering for CMS draft mode and a few server-only handlers. Both had to come from the same codebase.&lt;/p&gt;
&lt;p&gt;Next.js does not love this. API routes either exist or they do not, and the router resolves dynamic features at build time. Write something that depends on server rendering, then run a static export, and the build either fails or quietly produces something that breaks at runtime.&lt;/p&gt;
&lt;h2 id=&quot;the-mode-switch&quot;&gt;The mode switch&lt;/h2&gt;
&lt;p&gt;The fix was to treat the deploy target as data. An environment variable declared which mode the build was for, and the framework config read it. Routes that needed server rendering said so, and a static-export build left them out entirely. A Vercel build put them back. No second branch, no second config, one knob.&lt;/p&gt;
&lt;p&gt;Most routes did not care either way. The few that did, preview mode and the draft handlers and anything calling a server-only function, declared it once at the top of the route and let the build sort it out.&lt;/p&gt;
&lt;h2 id=&quot;the-suspense-cleanup&quot;&gt;The Suspense cleanup&lt;/h2&gt;
&lt;p&gt;Static export does not tolerate hooks that read URL state during prerender. Anything touching search params at render time had to sit inside a Suspense boundary, or the export would error or silently drop the page.&lt;/p&gt;
&lt;p&gt;I fixed it per component. Each form or widget that read search params got its own boundary around just the part that needed it, with a sensible fallback. Hoisting one boundary into a shared layout was tempting, but keeping them scoped made each diff small and each intent obvious.&lt;/p&gt;
&lt;h2 id=&quot;the-rule-it-left-behind&quot;&gt;The rule it left behind&lt;/h2&gt;
&lt;p&gt;The switch outlived the migration that prompted it. Every route written afterwards declares what it needs and gets resolved by the build, which means nobody has had to think about the two targets since. That was the actual deliverable. Not the dual deploy, but the fact that a second deploy target stopped being a thing anyone designs around.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;When the same code has to ship to two targets that disagree about what happens at build time and what happens at request time, forking is rarely the answer. Give every route a way to declare what it needs and let the build resolve it. The switch covered every route I wrote afterwards, which was the part that mattered.&lt;/p&gt;</content:encoded><category>Next.js</category><category>Architecture</category><category>Build</category></item><item><title>Building Interactive Pricing Pages with Next.js</title><link>https://vandre.dev/blog/building-interactive-pricing-pages/</link><guid isPermaLink="true">https://vandre.dev/blog/building-interactive-pricing-pages/</guid><description>How I built dynamic pricing pages with range sliders, plan comparisons, and real-time calculators for a SaaS product website.</description><pubDate>Sat, 15 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A pricing page has to behave like an app and load like a landing page. People expect sliders, calculators and live recalculation. They also expect it to appear instantly, rank well, and ship almost no JavaScript. Lean too far either way and you lose the other side.&lt;/p&gt;
&lt;p&gt;I built the pricing system for &lt;a href=&quot;https://getstream.io/video/pricing/&quot;&gt;a SaaS product site&lt;/a&gt; in Next.js: plan selection, live cost calculation, feature comparison tables, and layouts that held up across dozens of plan configurations.&lt;/p&gt;
&lt;h2 id=&quot;the-challenge&quot;&gt;The challenge&lt;/h2&gt;
&lt;p&gt;The product had several pricing tiers across a few product lines. Each product had its own plan structure, its own comparison table, and its own volume pricing. Some plans billed annually, some monthly. Some had usage calculators driven by sliders. All of it came from a CMS.&lt;/p&gt;
&lt;p&gt;I wanted one pricing system that could render any product’s page without copying code.&lt;/p&gt;
&lt;h2 id=&quot;dynamic-components&quot;&gt;Dynamic components&lt;/h2&gt;
&lt;p&gt;I built a set of components that rendered from CMS data: plan cards, comparison tables, volume discount sections, FAQ accordions, banners. Each one was a block the CMS could reorder, show or hide per product.&lt;/p&gt;
&lt;p&gt;The range slider was the most interesting piece. You drag to set your expected usage and the price updates as you go. It needed custom styling, keyboard support, and smooth movement between values. A native range input looks different in every browser, so I styled it with CSS custom properties and pseudo-elements.&lt;/p&gt;
&lt;h2 id=&quot;plan-selection-logic&quot;&gt;Plan selection logic&lt;/h2&gt;
&lt;p&gt;Every product had a different plan structure, and usage options changed the price. The plan button had to know the current plan, the selected plan, whether the change was an upgrade or a downgrade, and whether the visitor was signed in.&lt;/p&gt;
&lt;p&gt;I pulled that into a few utility functions. One helper returned the button’s attributes and a separate handler acted on the click. The components stayed simple and the logic stayed testable.&lt;/p&gt;
&lt;h2 id=&quot;comparison-tables&quot;&gt;Comparison tables&lt;/h2&gt;
&lt;p&gt;The tables had to work on a phone without scrolling sideways. I used a grid that collapsed into a stacked layout on small screens, repeating the plan name on each feature row so you never lost track of which column you were reading.&lt;/p&gt;
&lt;p&gt;Tooltips are CSS only. A JavaScript tooltip library is a lot of bundle for a marketing page.&lt;/p&gt;
&lt;h2 id=&quot;one-system-four-products&quot;&gt;One system, four products&lt;/h2&gt;
&lt;p&gt;“One pricing system for any product” was a goal when I wrote the first version. It is now how pricing ships for four of them: chat, video, activity feeds and moderation.&lt;/p&gt;
&lt;p&gt;What made it survive was keeping per-product behavior in slices over a shared base. A product that prices unusually adds a slice instead of forking the system, so the moderation cards and the feeds overage tables sit next to each other without either knowing the other exists. The video usage calculator eventually came out of the page altogether and now embeds on its own.&lt;/p&gt;
&lt;p&gt;I am also not the only one shipping into it. Seven other people have committed to the pricing code since, and it has kept absorbing new products for two years after the first version went out. That is the part I would call the actual result. Not the sliders, but the fact that adding a product line stopped being a conversation about the pricing page.&lt;/p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;Pricing pages are marketing pages that behave like applications. They need a dashboard’s interactivity and a landing page’s load time. The only way to get both is to decide, piece by piece, which side each one belongs to.&lt;/p&gt;</content:encoded><category>Next.js</category><category>Marketing</category><category>UI</category></item></channel></rss>