[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"source-alexopdev":3,"$fQa3xUpnRDPNiFFu2QS4K-DAjYmsapeR9fNhPNBdpkOc":-1,"articles-feed-\u002Fsources\u002Falexopdev-1--019d70dd-e3e7-76db-84a4-87b896dea004":11},{"articleCount":4,"category":5,"id":6,"name":7,"slug":8,"sourceType":9,"url":10},4,null,"019d70dd-e3e7-76db-84a4-87b896dea004","alexop.dev","alexopdev","rss","https:\u002F\u002Falexop.dev",{"items":12,"page":112,"pageSize":113,"totalCount":4},[13,44,67,93],{"content":14,"createdAt":15,"id":16,"image":17,"isAffiliate":18,"isPublished":19,"publishedAt":20,"slug":21,"sourceId":6,"sourceName":7,"sourceType":9,"summary":22,"title":23,"updatedAt":24,"url":25,"urlHash":26,"tags":27},"This is the written-up version of the talk I gave at Vue MAD 2026 in Madrid: Clean Code Is Sexy Again. Making Your Vue Project AI-Ready. If you would rather watch it, here is the original recording on YouTube. Otherwise, read on. TLDR My main take: what is good for developers is also good for agents. There was never a separate “AI-ready” checklist. There was just good engineering, and now it pays off twice. “You don’t have to write code anymore” is half true. It only works if you know your stack and your codebase is built for an agent to work in. An agent is not magic. It is a loop: read context, pick a tool, run it, read the result, repeat. Once you have built one you stop asking “how does the magic work” and start asking “why does the same loop fly in one repo and fall apart in another.” Three things every Vue project should invest in: context (AGENTS.md, skills, hooks, a brain\u002F), feedback loops (types, lint, tests, a real browser), and discoverability (vertical feature slices). When the agent gets something wrong, fix the factory, not the PR. Add the lint rule, update AGENTS.md, tighten the prompt. The PR fix is one bug. The factory fix prevents the next hundred. The line everyone keeps repeating Everyone keeps saying the same thing: you don’t have to write code anymore. I think that’s half true. In some of my repos AI 10x’d me. In others it produced complete garbage. Same person, same agent, same model, different codebases. It only works if two things are true. First, you have real experience with the stack. I would not let an agent write Rust for me, because I have no idea what good Rust looks like and I could not review it. Second, the codebase has to be shaped so an agent can work in it. A greenfield project is easy. A brownfield project with no tests and 10,000-line components is a mess that AI makes worse, not better, until you clean it up. A good frame for “where are we” comes from Dan Shapiro’s post on the levels of AI-assisted development. It reads like driving automation. Level 0 is spicy autocomplete, the first Copilot. Then agents arrive and it gets a lot better. At the top is the software factory where the agent defines, ships, and fixes on its own. I put myself at level three. The agent writes most of the code, and I spend my time reviewing and reading every line. Sometimes I feel like the bottleneck. I would not recommend level four or five for anything serious yet, but the pace is real. Two weeks before the talk, the creator of Bun started using Claude Code to rewrite Bun from Zig to Rust. A million lines, in days, almost entirely by an agent. Triumph or disaster, I don’t know. What I know is this is the world we ship into. What an agent actually is Most Vue devs treat agents as a magic box. Magic is the wrong mental model. The best tip I can give you to get better at AI is to build your own coding agent once. Then it stops being magic, and you get a feel for why it does the right thing sometimes and the wrong thing other times. I wrote up the full build in Building Your Own Coding Agent From Scratch, but here is the whole picture in one image. The agent is bounded by three things: its context window, the tools you give it, and its ability to verify what it did. That is it. The analogy that unlocks everything is treating the agent like a new engineer joining your team. You would not throw a new hire into your worst legacy module and expect a feature in a week. Same with an agent. Strip the magic and a tool is just a function. Three fields: a description, a schema for the arguments, and the function that runs. type Tool = { description: string schema: Record&lt;string, string&gt; execute: (args: Record&lt;string, unknown&gt;) =&gt; Promise&lt;string&gt; } const TOOLS = new Map&lt;string, Tool&gt;([ ['read', { description: 'Read file with line numbers (not a directory)', schema: { path: 'string', offset: 'number?', limit: 'number?' }, execute: read, }], ['edit', { description: 'Replace old with new in file (old must be unique)', schema: { path: 'string', old: 'string', new: 'string' }, execute: edit, }], ['bash', { description: 'Run shell command', schema: { cmd: 'string' }, execute: bash, }], ]) Here is the part that demystifies everything: the model only ever sees the description and the schema. The execute function never leaves your machine. The model cannot run read(), it cannot even see it. So how does it “use” a tool? It reads the description and emits a request: “call read with path src\u002FApp.vue.” Your loop runs the real function and feeds the result back. Which means the description is the prompt. A vague description is a tool the model misuses. Tool descriptions are engineering, not docs. And the loop is just recursion. async function agentLoop(messages, systemPrompt, tools = TOOLS) { const response = await callApi(messages, systemPrompt, tools) const toolResults = await processToolCalls(response.content) const newMessages = [ ...messages, { role: 'assistant', content: response.content }, ] \u002F\u002F No tool calls → the agent is done if (toolResults.length === 0) return newMessages \u002F\u002F Tool calls → loop with results as the next user turn return agentLoop( [...newMessages, { role: 'user', content: toolResults }], systemPrompt, tools, ) } Call the API. The model replies with text and maybe some tool requests. Run each one, append the assistant reply and the tool results, and loop. When the model returns no tool calls, it is done. And messages is not a database or a session store. It is an array of { role, content }. That array is the agent’s memory. Every turn you append to it. I built a tiny version of this a few months ago and called it nanocode, around 350 lines of TypeScript. It is not perfect, but building it is what made the whole thing click. Once you see an agent this way, the question stops being “how does the magic work” and becomes “why does the same simple loop work brilliantly in one repo and fall apart in another?” That is the rest of this post. Clean code isn’t nice-to-have anymore I have been in plenty of projects where people told me “we don’t have time to write tests.” That argument is over. You do not need new patterns for AI. The patterns you already fight for in code review, the ones the senior dev keeps insisting on, are exactly the patterns that make agents work. A codebase that is hard for humans is hard for agents. Sprawl, hidden coupling, magical state: humans hate it, agents fail at it. In Vue, three things are worth investing in: context, feedback loops, and discoverability. Part 1: Context The agent is Leonard from Memento. Every new chat, the context resets. No long-term memory, no yesterday. So, like Leonard, it has to tattoo the rules where it will read them every single turn. That tattoo is AGENTS.md (or CLAUDE.md). It is the first thing you can optimize. But the tattoo space is finite, and most of it is already used before you write a word. Every model has a context window, and it works best when it is not full. Models degrade as the window fills: recall drops, reasoning slips, the agent starts confusing files. And the window is not empty when you start. The system prompt and the tool definitions already cost around 20k tokens before you type anything. AGENTS.md, skills, MCP servers, and sub-agents all spend from the same pool. In Claude Code you can see exactly where it goes by typing \u002Fcontext. This is why the biggest mistake with AGENTS.md is dumping everything into it: every coding rule, every bug post-mortem, every gotcha, 2000 lines that load on every single turn. A better shape is a thin doorway: # AGENTS.md Run `pnpm lint:fix &amp;&amp; pnpm typecheck` after changes. ## Stack Nuxt 4, @nuxt\u002Fcontent v3, @nuxt\u002Fui v3 ## Structure - `app\u002F` — Vue application - `content\u002F` — Markdown files ## Further reading **IMPORTANT:** read the relevant doc below before starting any task. - `docs\u002Fnuxt-content-gotchas.md` - `docs\u002Ftesting-strategy.md` The agent loads testing-strategy.md only when it writes a test, nuxt-content-gotchas.md only when it touches content. That is progressive disclosure: the right context at the right time. Two filters before a line goes into AGENTS.md. Can a tool enforce it? Then do not write prose about it. Is it universal or situational? Situational goes in docs\u002F. I go deeper on the AGENTS.md-versus-skills split in my Claude Code customization guide, and on why context fills up the way it does in reverse-engineering HumanLayer’s context engineering. brainmaxxing Forget looksmaxxing. We are brainmaxxing: maxing the one thing the agent has, its context. brainmaxxing is an open-source kit from a Cursor developer. It is a framework for giving an agent a documented memory, and it has three pieces. First, a brain\u002F folder. Plain markdown. The agent reads from it and writes back to it. There is one index.md with wiki-links describing the documentation that exists, and the rest is markdown files for codebase notes, plans, and principles. People found out you do not need RAG or anything complex. The simplest approach, wiki-links and markdown files, works best, and now the agent has a memory. (If you want the theory of why files beat a vector database here, I wrote it up in the four types of memory for coding agents.) Second, skills. A skill is like a prompt that lives in a markdown file with a name and a description. When you start Claude Code, only the name and description enter the context. When your prompt matches, the body loads. When the body calls a script, the script loads. Three levels of lazy loading, so skills scale. brainmaxxing’s \u002Freflect skill is a good example: at the end of a session it reviews the conversation and persists what mattered back into brain\u002F. So when the agent uses the wrong command and you correct it, you just say “remember this,” and it updates its own memory. Third, hooks. Vue has lifecycle hooks like onMounted. Claude Code has lifecycle hooks too. brainmaxxing wires a SessionStart hook that cats index.md into every new session, so the agent boots up already knowing the map. There are many more events. PreToolUse can block a destructive command or forbid reading your .env. PostToolUse can re-lint after an edit. SessionEnd could even send you a notification. I cover skills and hooks hands-on in my Vue workout tracker walkthrough. brainmaxxing also ships principles, around 16 markdown files describing how you want code written. One is boundary discipline: business logic lives in pure functions, the shell is thin and mechanical. The agent reads them and develops a better taste for your code. You can write your own too. Claude Code itself now ships something similar, called auto memory: it writes markdown notes about your project automatically. The catch is those files only live on your machine. That is exactly why I like keeping brain\u002F in Git. Check it in and the whole team’s agent boots up with the same memory on day one. The new hire on Monday starts where the team is, not from zero. Want VueUse-style code? Give the agent VueUse One more trick, and it is bigger than memory. You want the agent to use VueUse or Nuxt UI, but its training data is out of date and it does not know the current API. So give it the source. Clone the library into your repo as a git subtree: git subtree add --prefix=repos\u002Fvueuse \\ https:\u002F\u002Fgithub.com\u002Fvueuse\u002Fvueuse main --squash Then point AGENTS.md at it: ## Reference repositories - `repos\u002Fvueuse\u002F` — when writing new composables, mirror the patterns in `repos\u002Fvueuse\u002Fpackages\u002Fcore\u002Fuse*\u002F`. Now the agent’s pattern matching is anchored on real source instead of stale docs. Agents are post-trained on reading code, not prose. A subtree beats a submodule (no clone-time pain, just files) and beats pointing at node_modules (compiled and flattened, the structure is gone). One refinement: tell your editor to ignore repos\u002F so you do not accidentally auto-import VueUse internals, and have the agent distill what it learned into a short agent-patterns\u002Fvueuse.md so it does not re-explore 800 composables every session. This sounds stupid, but once you try it, it works far better than MCPs in my experience. The pattern comes from the Effect team’s post, The One Weird Git Trick That Makes Coding Agents More Effect-ive. Part 2: Feedback loops If an agent does not know when it broke something, it ships whatever compiles. So you give it deterministic ways to check its own work. Type safety is the first lie-detector. A strict tsconfig is non-negotiable, and noUncheckedIndexedAccess and exactOptionalPropertyTypes catch what default strict mode lets through. Types are a compile-time fiction, though, so parse with Zod or Valibot at every untyped boundary: fetch responses, route params, env vars, form input. Lint and format with Oxlint and Oxfmt. They are roughly 50x and 30x faster than ESLint and Prettier, fast enough to run on every save instead of batching at the end. Two Vue lint rules in particular keep components agent-friendly, because nobody, human or agent, can read a 3000-line component: 'vue\u002Fmax-template-depth': ['error', { maxDepth: 8 }] 'vue\u002Fmax-props': ['error', { maxProps: 6 }] A 12-prop component is a god object pretending to be one. I wrote up my full set in my opinionated ESLint setup for Vue projects, including custom local rules. Then tests. Vitest for units, and the big 2026 win, Vitest browser mode, which runs component tests in a real Chromium via Playwright instead of jsdom. Hover, focus, layout, scroll, all behave like production. The agent can finally trust that “passes in tests” means “works in a browser.” I go deep on this in the Vue testing pyramid with Vitest browser mode. Those are four layers. There are 11 more: API mocking, contract tests, E2E, accessibility, visual regression, performance, dead-code detection, and so on. Every one is a signal the agent can chase to green on its own. The cheap ones at the center run on every save, the expensive ones at the edge run in CI. What I now do in a new project is hand the agent my own write-up and say “set up the layers that make sense here.” The full breakdown lives in the modern frontend quality pipeline. The agent as a user Static checks can be green and the feature can still be broken. Did the modal open? Did the chart render? You need the agent to be the user. Vercel’s agent-browser is a browser CLI shaped for agents. One install and the agent has a real Chromium it can drive from the command line. npm i -g agent-browser agent-browser install agent-browser open localhost:5173 agent-browser snapshot -i # DOM tree with @refs agent-browser click @e2 # click by ref agent-browser screenshot --annotate agent-browser console # read page console The snapshot returns the DOM as plain text with refs, the agent clicks by ref, screenshots come back annotated, console errors come back as text. So when there is a bug, you say “reproduce it with agent-browser,” and once the agent can reproduce something, fixing it is easy. The same thing a good developer would do. For this to work the app has to be agent-runnable: the dev server is one command, the port is stable, there is a way to know the server is ready, and test data is seeded so the agent does not hit a login wall. Then “types pass” is no longer the bar. “Works in a browser” is. I built a full version of this into CI in automated QA with Claude Code, agent-browser, and GitHub Actions. Finally, put a gate at commit time. Lefthook (or Husky) runs lint, typecheck, and the related tests on every commit, so bad code does not reach main. I prefer Lefthook because it runs jobs in parallel and the config is YAML the agent can read and edit, not a shell-script soup. The one rule: no --no-verify escape hatch. The whole point of the gate is that it does not open. # lefthook.yml pre-commit: parallel: true jobs: - run: pnpm oxlint - run: pnpm vue-tsc --build - run: pnpm vitest related --run {staged_files} Part 3: Discoverability Most Vue apps start flat. You run create vue and you get components\u002F, composables\u002F, stores\u002F, views\u002F, router\u002F. Everything is grouped by what a file is. That is fine for small apps, but in a real application that components\u002F folder grows to 80+ files, and a single “checkout” change is scattered across three folders. The agent has the same problem you do: it greps across the whole tree and most of what it pulls back is noise. Feature slicing groups by what files do, not by what they are. First you find the domains. Imagine a workout tracker: the domains are workout, timers, exercises, and settings. Then each domain owns its own components, composables, and store: src\u002Ffeatures\u002F ├── workout\u002F │ ├── components\u002F │ ├── composables\u002F │ └── store.ts ├── timers\u002F ├── exercises\u002F └── settings\u002F Finding the domains is the hard part on a big codebase, but once you have them, “change the settings page” means opening one folder. Same files, same code, different addressing. And the win for the agent is concrete. Ask it to “add a streak counter to the active workout.” In a flat layout it greps components\u002F and composables\u002F and pulls back SettingsForm, TimerDisplay, useSettings, maybe 40% relevant. In a feature layout it lists features\u002Fworkout\u002F and gets exactly the files that matter, 100% relevant. No grep, no guess, tokens go to output instead of search. Folders alone do not keep the agent honest, so add three import rules. Arrows only point down the layers. Sibling features never import each other; if they share something, it moves to a shared layer like components\u002F or lib\u002F. And a page that uses two features composes them at the page level rather than letting one feature reach into another. The most important rule is the second one: features stay decoupled. As a bonus, a codebase split this way is already shaped for a micro-frontend approach if you ever need it. Where this is heading AFK coding (away from keyboard) is already here. You write a spec, kick off an agent, and walk away. It runs the tests, hits a failure, fixes itself, runs again, and opens a draft PR with a summary. You review. The work is no longer “me typing.” It is “me deciding what should exist, and reviewing what came back.” The loop in practice has humans at the edges and agents in the middle. You align with the business on the spec, the agent breaks the big ticket into vertical sub-tickets, one agent works each slice with TDD inside, a dedicated refactor pass runs (the step LLMs always skip), a QA agent drives the real browser, and then you read the PR. I wrote the long version in how to do AFK coding. The most important thing in this loop is what you do when it goes wrong. The agent ships a bug, and the instinct is to fix the bug, merge, and move on. That instinct is wrong. Fix the factory, not the PR. A bug is not a bug, it is a factory defect. Add an ESLint rule that catches that whole class of mistake. Update AGENTS.md so the convention is written down. Tighten the slash command or the prompt if that is where it leaked. The PR fix is one bug. The factory fix prevents the next hundred. And if the codebase is messy in the first place, use the agent to refactor it before you expect any of this to work. Every PR review teaches the factory, and the codebase gets smarter over time. As a test of all this, the week before the talk I tried to port React Ink (declarative components for terminal UIs) to Vue, using AI only. It worked surprisingly well. The approach combined everything above. I vendored Ink, Vue core, and VueUse into repos\u002F as read-only source. I used a brain\u002F vault for gotchas and an api-tracker. And because React Ink has a lot of tests, I ported the tests first: each Ava scenario became a Vitest test at the behavior level, not the React implementation. Run it, red, no implementation yet. Then translate the implementation, JSX to SFC, hooks to composables. Run it, green. Then reflect the learnings back into brain\u002F so the loop sharpens itself. I would not publish it as a real library without going back and understanding every line first, but as a proof that you can port a well-tested React library to Vue mostly AFK, it holds up. I even added a Stop hook that fires when a turn ends and spawns a second, headless Claude in the background to read the session transcript and write only new durable learnings into brain\u002F. The one gotcha is that the child also ends a turn, which would fire the hook forever, so an env flag marks the child and the hook bails when it sees it. The project updates its own docs while I get coffee. The twist This talk was not really about AI. I tricked you. None of it is new. Testing, TDD, feature-based architecture, strict types, small components: these are the things senior engineers have fought for in code review for twenty years. They were good before a single agent existed. The only thing that changed is the payoff. The same discipline that used to make a codebase pleasant for the next human now also makes it tractable for an agent, so every hour you spent caring about architecture quietly started earning twice. That is the whole bet. Tight context, plenty of feedback loops, real discoverability. Which of them is your project missing? Come find me and tell me what your AGENTS.md looks like.","2026-06-27T16:00:02.356Z","019f09cf-3518-7096-8fef-95033e6e53c2","https:\u002F\u002Fi.ytimg.com\u002Fvi\u002F9bKMqvFRvvI\u002Fhqdefault.jpg",false,true,"2026-06-27T00:00:00.000Z","clean-code-is-sexy-again-making-your-vue-project-ai-ready","The article discusses the importance of clean code in making Vue projects ready for AI integration, emphasizing that good engineering practices benefit both developers and AI agents. It highlights the need for context, feedback loops, and discoverability in projects to enable effective AI assistance, while cautioning that AI's effectiveness depends on the developer's familiarity with the stack and the structure of the codebase.","Clean Code Is Sexy Again: Making Your Vue Project AI-Ready","2026-06-29T20:00:17.397Z","https:\u002F\u002Falexop.dev\u002Fposts\u002Fclean-code-is-sexy-again-vue-ai-ready\u002F","308ed1fc6ac401b781ff16aa2e321e0a152bfe1110030261e6875e67b16f015c",[28,32,35,38,41],{"color":29,"id":30,"name":31,"slug":31},"#10b981","019d6bd8-c9a8-7783-bd22-03145b355427","vue",{"color":29,"id":33,"name":34,"slug":34},"019d9d20-e07b-7685-9ebb-fae0b963f243","ai",{"color":29,"id":36,"name":37,"slug":37},"019f09cf-5bcd-751c-8d46-9e123bd784af","clean-code",{"color":29,"id":39,"name":40,"slug":40},"019dafe9-8a6d-7218-81f1-37052cbe9b78","development",{"color":29,"id":42,"name":43,"slug":43},"019d6bd9-010e-772d-b2f0-9378a042a676","best-practices",{"content":45,"createdAt":46,"id":47,"image":48,"isAffiliate":18,"isPublished":19,"publishedAt":49,"slug":50,"sourceId":6,"sourceName":7,"sourceType":9,"summary":51,"title":52,"updatedAt":53,"url":54,"urlHash":55,"tags":56},"Claude Code shipped workflows recently, and the docs describe a lot of machinery: deterministic orchestration, parallel and pipeline, journaling and resume, adversarial verify patterns. I wanted to understand it rather than skim the feature list, and the way I learn a tool is to build the smallest real thing with it. So I picked a task with an obvious fan-out shape: “what happened in the Vue and Nuxt ecosystem this week.” Many independent sources to check, then a merge, then a write-up. I wrote a ~130-line workflow that spawns nine agents in parallel, each scouring a different source, collects their findings into one list, ranks them by impact, and writes a digest. It’s a throwaway, but building it taught me how the whole feature fits together. This post is what I learned. A workflow is the newest piece of Claude Code’s orchestration story. In my post on agent teams I traced the progression from subagents to teams. Workflows are the next rung, and they solve a different problem than either: when you want the control flow itself to be deterministic, not decided turn-by-turn by a model. If you want a sense of the ceiling before the toy example, Jarred Sumner credited dynamic workflows and adversarial code review for porting Bun from Zig to Rust in six days: &lt;TLDR items={[ “A workflow is a plain JavaScript script that orchestrates subagents deterministically: you own the loops and fan-out, agents do the thinking”, “The shape that generalizes: fan out → reduce → synthesize”, “agent() runs one subagent (use a schema for validated JSON), parallel() is a barrier, pipeline() streams items through stages with no barrier”, “Default to pipeline(); reach for a parallel() barrier only when a stage needs all prior results at once”, “Compose verify\u002Fjudge\u002Floop-until-dry patterns for confidence, not more agents”, “It’s opt-in and token-hungry, so reach for it when a job needs breadth, verification, or scale a single context can’t hold”, ]} \u002F&gt; Table of Contents Where Workflows Fit Most of the time a single Claude Code session works turn-by-turn: read a file, decide, call a tool, look at the result, decide again. That loop is the right tool for most work. Some jobs don’t fit one head and one context window though: Comprehensive jobs: “review every file in this diff”, “audit all 40 dependencies”. Confidence-critical jobs: “find the bug, then have three independent skeptics try to refute it”. Scale jobs: migrations, sweeps, anything bigger than one context can hold. Subagents and agent teams can attack these, but there’s a subtle difference in who holds the plan. Subagents Agent Teams Workflows What it is A worker Claude spawns Independent Claude sessions A script the runtime executes Who decides what’s next Claude, turn by turn Claude and the teammates The script Where results live Claude’s context Each session’s context Script variables What’s repeatable The worker definition The team setup The orchestration itself Scale A few per turn A handful of sessions Dozens to hundreds of agents With subagents and skills, Claude is the orchestrator. It decides turn by turn what to spawn, and every result lands back in its context window. A workflow moves the plan into code. The script holds the loop, the branching, and the intermediate results, so Claude’s context only ever sees the final answer. That is what lets a workflow scale to hundreds of agents without drowning the conversation. The Core Idea A normal agent decides the control flow as it goes. A workflow inverts that. You write the control flow as plain code, and each individual step is delegated to a fresh subagent. The orchestration is deterministic; only the work inside each agent() call is model-powered. That distinction is the whole point. When you write this: const results = await parallel(files.map((f) =&gt; () =&gt; agent(`Review ${f}`))); You know exactly one agent runs per file, they all run concurrently, and you get an array back. There are no emergent “the model decided to skip three files” surprises. You get determinism in the orchestration and model judgment inside each step. The shape that keeps showing up is fan out → reduce → synthesize: &lt;Mermaid chart={graph LR A[fan out] --&gt; B[agent 1] A --&gt; C[agent 2] A --&gt; D[agent ...] A --&gt; E[agent N] B --&gt; F[reduce: dedupe + rank] C --&gt; F D --&gt; F E --&gt; F F --&gt; G[synthesize: write the result]} \u002F&gt; Swap the sources and prompts and the same skeleton becomes a market scan, a dependency audit, a code review, or a research report. The Example I Built to Learn It Here is the workflow I wrote. I picked the newsletter task because it forces you to use every part of the feature: a wide fan-out, a reduce step, and a synthesis step. Every script starts with a meta block that must be a pure literal, then a body using the orchestration primitives. export const meta = { name: \"vue-newsletter\", description: \"Research Vue\u002FNuxt sources in parallel and synthesize a newsletter\", phases: [ { title: \"Research\", detail: \"one agent per source\" }, { title: \"Curate\", detail: \"dedupe + rank by impact\" }, { title: \"Write\", detail: \"synthesize the newsletter\" }, ], }; 1. Fan out with parallel() Nine sources, nine agents, all at once. Each returns structured JSON validated against a schema, so the model retries on mismatch and I never parse free text: phase(\"Research\"); const raw = await parallel( SOURCES.map((s) =&gt; () =&gt; agent(s.prompt, { label: `research:${s.key}`, phase: \"Research\", schema: ITEM_SCHEMA, \u002F\u002F forces validated structured output agentType: \"general-purpose\", }), ), ); The SOURCES array is just data: one entry per source with a prompt. GitHub core releases, the Nuxt ecosystem, the official blogs, Hacker News, Reddit, dev.to, key people like Evan You and Anthony Fu, and the newsletter\u002Fpodcast circuit. 2. Reduce with plain JavaScript Flattening, deduping, and filtering is just code. No agent needed: const collected = raw.filter(Boolean); \u002F\u002F skipped\u002Ffailed agents become null const flatItems = collected.flatMap((c) =&gt; c.items); log(`Collected ${flatItems.length} items`); 3. Synthesize with sequential agent() calls phase(\"Curate\"); const curated = await agent(curatePrompt, { phase: \"Curate\", schema: CURATED_SCHEMA }); phase(\"Write\"); const newsletter = await agent(writePrompt, { phase: \"Write\" }); return { newsletter, itemCount: flatItems.length, curated }; The run I did while testing pulled together a Nuxt UI release, a Vue Router v5 minor, a Vue core patch, and a Madrid conference recap: seventeen items across nine sources in about three minutes. Good enough to convince me the orchestration worked, which was the whole point of building it. The `schema` option forces a subagent to call a structured-output tool, and validation happens at the tool-call layer so the model retries on mismatch. This is far more reliable than asking an agent to \"please return JSON\" and hoping. Reach for it whenever a downstream stage consumes the result. The Primitives A handful of functions do all the work. agent(prompt, opts?) spawns one subagent. Without options it returns the agent’s final text. The options worth knowing: schema: a JSON Schema. The subagent is forced to return validated structured data. label: the display name in the progress UI. phase: assigns the agent to a progress group. Use it inside parallel() and pipeline() to avoid racing on the global phase() state. model: override the model for this one call. Default is to omit it so the agent inherits your session model. agentType: use a custom subagent type instead of the default workflow agent. isolation: \"worktree\": run the agent in its own git worktree. Only when agents write files in parallel and would otherwise conflict. parallel(thunks) runs tasks concurrently. It is a barrier: it waits for every thunk before returning. A thunk that throws resolves to null rather than rejecting the whole call, so always .filter(Boolean) the results. You can pass a hundred thunks and they’ll all complete, but only a handful run at once: concurrency is capped at roughly your core count, and the excess queue. pipeline(items, ...stages) runs each item through all stages independently, with no barrier between stages. Item A can be in stage 3 while item B is still in stage 1. Each stage callback receives (prevResult, originalItem, index). workflow(nameOrRef, args?) runs another workflow inline as a sub-step and returns whatever it returns. Pass a name to invoke a saved workflow, or { scriptPath } to run a script file. This is composition: a research workflow can call \u002Fdeep-research as one of its stages instead of reimplementing the fan-out. The child shares the parent’s concurrency cap, agent counter, and token budget, and shows up as its own group in \u002Fworkflows. Nesting is one level deep: a workflow() call inside a child throws. \u002F\u002F inside a script: hand a sub-question off to the bundled deep-research workflow const report = await workflow(\"deep-research\", { question: topic }); The rest are small helpers: phase(title) starts a progress group, log(msg) emits a narrator line, args carries the JSON you passed in when launching, and budget exposes the token target so you can scale depth dynamically (it’s null when you launch without a target, so guard any loop-until-budget on budget.total or it runs to the agent cap). `Date.now()`, `Math.random()`, and an argless `new Date()` all throw inside a workflow. Workflows journal every `agent()` call so a run can resume, and non-determinism would invalidate that cache. If you need a timestamp, pass it through `args`. If you need variety across agents, vary the prompt or label by index. pipeline vs parallel: The Decision That Matters This trips people up, so here is the rule I follow. Default to pipeline(). Reach for a parallel() barrier between stages only when a stage needs all prior results at once. Legitimate reasons for a barrier: ✅ Dedupe or merge across the full result set before expensive downstream work. ✅ Early-exit on the total (“0 findings, skip verification entirely”). ✅ A prompt that references “the other findings” for comparison. Not legitimate: ❌ “I need to flatten or filter first.” Do it inside a pipeline stage. ❌ “The stages feel conceptually separate.” Separate is not the same as synchronized. ❌ “It’s cleaner code.” Barrier latency is real wall-clock waste. The smell test: if you wrote parallel → transform → parallel, and that middle transform has no cross-item dependency, you should have used a pipeline. The newsletter example does use a barrier, and correctly: curation has to see every source before it can dedupe and rank across them. Quality Patterns The primitives compose into reusable harnesses. This is the real value over spawning more agents: the structure is what produces confidence. A few I lean on: Adversarial verify: for each finding, spawn N independent skeptics prompted to refute it. Kill it unless a majority survive. Stops plausible-but-wrong findings from shipping. Perspective-diverse verify: give each verifier a distinct lens (correctness, security, performance, does-it-reproduce) instead of N identical ones. Diversity catches failure modes redundancy can’t. Judge panel: generate N attempts from different angles, score with parallel judges, synthesize from the winner while grafting the best of the runners-up. Loop-until-dry: for unknown-size discovery, keep spawning finders until K consecutive rounds surface nothing new. Here is loop-until-dry with a diverse-lens verify, condensed: const seen = new Set(); const confirmed = []; let dry = 0; while (dry &lt; 2) { const found = (await parallel(FINDERS.map((f) =&gt; () =&gt; agent(f.prompt, { phase: \"Find\", schema: BUGS })))).filter(Boolean).flatMap((r) =&gt; r.bugs); const fresh = found.filter((b) =&gt; !seen.has(key(b))); if (!fresh.length) { dry++; continue; } dry = 0; fresh.forEach((b) =&gt; seen.add(key(b))); const judged = await parallel(fresh.map((b) =&gt; () =&gt; parallel([\"correctness\", \"security\", \"repro\"].map((lens) =&gt; () =&gt; agent(`Judge \"${b.desc}\" via the ${lens} lens — real?`, { phase: \"Verify\", schema: VERDICT }))) .then((vs) =&gt; ({ b, real: vs.filter(Boolean).filter((v) =&gt; v.real).length &gt;= 2 })))); confirmed.push(...judged.filter((v) =&gt; v.real).map((v) =&gt; v.b)); } One detail makes or breaks this: dedupe against everything seen, not just confirmed results. Otherwise rejected findings reappear every round and the loop never converges. A Shipped Example: How \u002Fdeep-research Works My newsletter generator is a toy. If you want to see these patterns in a real, bundled workflow, run \u002Fdeep-research. It takes a question and returns a cited report, and under the hood it’s the same fan out → reduce → synthesize skeleton with an adversarial verify pass bolted on. It’s the quality pattern from the section above, running in production. When you launch it the workflow announces its plan and runs in the background while you keep working: It moves through five phases: Scope: one agent decomposes your question into five distinct search angles, so the searches don’t all chase the same wording. Search: five web searches run in parallel, one per angle. This is the fan-out. Fetch: dedupe the URLs across angles, pull the top ~15 sources, and extract individual claims from them. Verify: the interesting part. Each claim gets an adversarial three-vote check, with skeptics trying to refute it. Claims that don’t survive never reach the report. Synthesize: one final agent writes the cited report from the claims that held up. Map that onto the primitives and you can almost see the script: a single agent() for scope, a parallel() fan-out for the five searches, plain JavaScript to dedupe in fetch, a per-claim verify pass (the same parallel() of skeptics from the loop-until-dry example), and a closing agent() to synthesize. The phases show up in \u002Fworkflows as named groups (Scope 1\u002F1, Search 0\u002F5, Fetch, Verify, Synthesize), each with its own agent count, token total, and elapsed time, so you can drill into any single search or verification and read its prompt and result. This is the difference between “ask Claude to research something” and a workflow. A single agent doing web research holds every half-read source in one context and never checks its own claims. \u002Fdeep-research decomposes the search so coverage is wide, keeps the intermediate sources out of your conversation, and runs a verification pass a single turn-by-turn agent would never run against itself. Triggering and Watching a Run Worth saying plainly: from Claude Code’s side, a workflow is a tool. There’s a Workflow tool the same way there’s a Read or Bash tool, and “running a workflow” means Claude calls that tool with a script. The runtime executes the script in the background while your session stays responsive, which is why you can keep chatting while dozens of agents churn away. There are a few ways a workflow gets written and launched: Say “workflow” in your prompt. Include the word and Claude writes a workflow script for the task instead of working through it turn by turn. Run a saved or bundled command. A workflow you saved to the project, or the built-in \u002Fdeep-research covered above. Turn on ultracode. Claude plans a workflow for every substantial task in the session. Run a workflow to audit every API endpoint under src\u002Froutes\u002F for missing auth checks. Spawn one agent per route file, then have a second pass verify each finding before reporting. When a run does what you wanted, you can save it: Claude Code writes the script into .claude\u002Fworkflows\u002F in your repo as a &lt;name&gt;.js file (the appendix below is exactly that file for my newsletter). Because it lives in the repo, it’s version-controlled and anyone who clones it can launch it by name and pass arguments: Run the vue-newsletter workflow with args `{\"weekStart\":\"2026-06-04\",\"weekEnd\":\"2026-06-11\"}` Runs happen in the background, and \u002Fworkflows is how you watch them: it lists every run, including which ones are currently running, and opens a progress view showing each phase with its agent count, token total, and elapsed time. You can drill into a phase, then into a single agent, to read its prompt and result, pause or stop a run, or press s to save a good one’s script as a reusable \u002F&lt;name&gt; command under .claude\u002Fworkflows\u002F. When to Reach for One &lt;Mermaid chart={graph TD A[Does the job need breadth,&lt;br\u002F&gt;verification, or scale?] --&gt;|No| B[Single session&lt;br\u002F&gt;or a subagent] A --&gt;|Yes| C[Do you want the control flow&lt;br\u002F&gt;to be deterministic and repeatable?] C --&gt;|No| D[Agent team] C --&gt;|Yes| E[Write a workflow]} \u002F&gt; Good fit ✅ Decomposing a job so every part is covered in parallel (audits, reviews, sweeps). Anything you want to re-run with the same structure (a weekly competitor scan, a release checklist). Confidence-critical work where a repeatable verify or judge pass beats one model’s first answer. Bad fit ❌ An ordinary task one agent can do turn by turn. Let one agent do it. Work that needs you to weigh in between every stage. A workflow can’t take mid-run input; only agent permission prompts pause it. Anything where the token cost of dozens of agents isn’t justified by breadth or scale. A workflow spawns many agents, so one run can use meaningfully more tokens than doing the same task in conversation, and it counts toward your plan's usage. Every agent uses your session's model unless the script routes a stage elsewhere, so check `\u002Fmodel` before a large run and consider routing cheap stages to a smaller model. Conclusion A workflow is a JavaScript script that orchestrates subagents deterministically. You own the control flow, agents do the thinking, and the plan lives in code so the conversation only sees the final answer. The shape that generalizes is fan out → reduce → synthesize. The newsletter generator I built is a deliberately small instance of it. agent() runs one (use a schema for validated structured output), parallel() is a barrier, pipeline() streams items through stages with no barrier. Default to pipeline. The leverage is the repeatable quality patterns: adversarial verify, diverse lenses, judge panels, loop-until-dry. It is opt-in and token-hungry. Reach for it when a job needs breadth, independent verification, or scale a single context can’t hold. Otherwise let one agent do the work. If you’ve already worked through subagents and skills, workflows are the natural next tool. The fastest way to understand them is the same way I did: pick a small task with a clear fan-out shape and build the throwaway version. Mine was a newsletter generator I won’t run again. The point was never the newsletter; it was seeing how the pieces fit, so that when a job needs breadth or verification, reaching for a workflow is obvious. Appendix: The Full Script Everything above is excerpts. Here is the complete .claude\u002Fworkflows\u002Fvue-newsletter.js in one piece, so you can see how the meta block, the schemas, the source list, and the three phases fit together. It’s plain JavaScript: no imports, no filesystem access, inputs via args, results via return. export const meta = { name: 'vue-newsletter', description: 'Research Vue\u002FNuxt ecosystem sources in parallel for a given week and synthesize a newsletter', whenToUse: 'Generate a weekly Vue\u002FNuxt newsletter. Pass args {weekStart, weekEnd, label} as ISO dates (e.g. {\"weekStart\":\"2026-05-21\",\"weekEnd\":\"2026-05-28\"}). With no args, agents cover the past 7 days from today.', phases: [ { title: 'Research', detail: 'one agent per source — releases, blogs, social, people' }, { title: 'Curate', detail: 'dedupe + rank items by impact' }, { title: 'Write', detail: 'synthesize the final newsletter' }, ], } \u002F\u002F Args are optional. Pass {weekStart, weekEnd, label} as ISO dates to scope a specific week. \u002F\u002F With no args, agents are told to cover \"the past 7 days from today\" (they resolve the date via web search). const hasRange = args &amp;&amp; args.weekStart &amp;&amp; args.weekEnd const weekStart = hasRange ? args.weekStart : null const weekEnd = hasRange ? args.weekEnd : null const label = (args &amp;&amp; args.label) || (hasRange ? `Week of ${weekStart}–${weekEnd}` : 'this week') const window = hasRange ? `between ${weekStart} and ${weekEnd}` : 'within the past 7 days from today' const ITEM_SCHEMA = { type: 'object', additionalProperties: false, properties: { source: { type: 'string' }, items: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { title: { type: 'string' }, url: { type: 'string' }, summary: { type: 'string', description: '1-3 sentence plain summary of what changed \u002F why it matters' }, category: { type: 'string', enum: ['release', 'article', 'tooling', 'discussion', 'tutorial', 'people', 'other'] }, date: { type: 'string', description: 'ISO date if known, else empty' }, impact: { type: 'string', enum: ['high', 'medium', 'low'] }, }, required: ['title', 'url', 'summary', 'category', 'impact'], }, }, }, required: ['source', 'items'], } \u002F\u002F Each source is researched by its own agent in parallel. const SOURCES = [ { key: 'core-releases', prompt: `Find releases\u002Fchangelogs published ${window} for these GitHub repos: vuejs\u002Fcore, vuejs\u002Frouter (vue-router), vuejs\u002Fpinia, vueuse\u002Fvueuse, vitejs\u002Fvite, vitejs\u002Fvitest. For each new release in that window, give the version, the highlights, and the release URL. Skip anything outside the date window.`, }, { key: 'nuxt-releases', prompt: `Find releases\u002Fchangelogs published ${window} for the Nuxt ecosystem on GitHub: nuxt\u002Fnuxt, nuxt\u002Fui, nuxt\u002Fimage, nuxt\u002Fcontent, unjs\u002Fnitro, unjs\u002Fh3. Give version, highlights, and URL for each release in that window only.`, }, { key: 'vue-blog', prompt: `Check the official Vue.js blog (blog.vuejs.org) and Vue.js news for posts published ${window}. Summarize each post with its URL.`, }, { key: 'nuxt-blog', prompt: `Check the official Nuxt blog (nuxt.com\u002Fblog) for posts published ${window}. Summarize each with URL.`, }, { key: 'hackernews', prompt: `Search Hacker News (news.ycombinator.com) for stories about Vue, Nuxt, Vite, or Pinia that were active\u002Fposted ${window}. Include the HN discussion URL and the linked article. Note points\u002Fcomments if visible.`, }, { key: 'reddit', prompt: `Search Reddit r\u002Fvuejs and r\u002FNuxt for notable threads posted ${window} — announcements, releases, popular discussions, showcased projects. Give the reddit thread URL for each.`, }, { key: 'devto', prompt: `Search dev.to for the most useful Vue and Nuxt tagged articles published ${window} (tutorials, deep-dives, tips). Give URLs.`, }, { key: 'people', prompt: `Look for notable updates, posts, or talks ${window} from key Vue\u002FNuxt people: Evan You (@youyuxi \u002F VoidZero), Daniel Roe (Nuxt lead), Anthony Fu (VueUse\u002FVitesse\u002FSlidev), Eduardo San Martin Morote (posva — router\u002Fpinia), Sébastien Chopin (Nuxt\u002FNuxtLabs). Include VoidZero and NuxtLabs company news too. Give URLs.`, }, { key: 'newsletters-podcasts', prompt: `Find Vue\u002FNuxt newsletter issues and podcast episodes published ${window}: Vue.js Newsletter (news.vuejs.org), This Week in Vue, Michael Thiessen's newsletter, DejaVue podcast, Deox\u002FVue Mastery content. Summarize and give URLs.`, }, ] phase('Research') const raw = await parallel( SOURCES.map((s) =&gt; () =&gt; agent( `You are researching the Vue.js \u002F Nuxt ecosystem for a weekly newsletter covering ${label} (${window}).\\n\\n${s.prompt}\\n\\nUse web search and fetch real URLs. Only include items genuinely within the date window. Return real, verifiable URLs — never invent links. If you find nothing in the window, return an empty items array. Set impact based on how much the average Vue developer should care.`, { label: `research:${s.key}`, phase: 'Research', schema: ITEM_SCHEMA, agentType: 'general-purpose' }, ), ), ) const collected = raw.filter(Boolean) const flatItems = collected.flatMap((c) =&gt; (c.items || []).map((it) =&gt; ({ ...it, source: c.source }))) log(`Collected ${flatItems.length} items across ${collected.length} sources`) phase('Curate') const CURATED_SCHEMA = { type: 'object', additionalProperties: false, properties: { highlights: { type: 'array', items: { type: 'string' }, description: '2-4 sentence TLDR bullets of the biggest stories this week' }, items: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { title: { type: 'string' }, url: { type: 'string' }, summary: { type: 'string' }, category: { type: 'string' }, impact: { type: 'string' }, }, required: ['title', 'url', 'summary', 'category', 'impact'], }, }, }, required: ['highlights', 'items'], } const curated = await agent( `Here are raw newsletter candidate items gathered from multiple sources for the Vue\u002FNuxt week of ${label}:\\n\\n${JSON.stringify(flatItems, null, 2)}\\n\\nCurate them:\\n1. Remove duplicates (same release\u002Farticle surfaced by multiple sources — keep the best canonical URL).\\n2. Drop low-quality, off-topic, or spammy entries.\\n3. Rank by impact (high first).\\n4. Write 3-5 punchy \"highlights\" bullets capturing the week's biggest stories.\\nKeep every URL exactly as provided — do not fabricate or alter links.`, { phase: 'Curate', schema: CURATED_SCHEMA }, ) phase('Write') const newsletter = await agent( `Write a polished weekly Vue.js \u002F Nuxt newsletter in Markdown for ${label}.\\n\\nUse this curated data:\\n${JSON.stringify(curated, null, 2)}\\n\\nStructure:\\n- A title with the week range and a one-paragraph intro setting the tone.\\n- \"📌 This Week's Highlights\" — the highlights bullets.\\n- \"🚀 Releases\" — version bumps with what changed (group Vue core + Nuxt + tooling).\\n- \"📝 Articles &amp; Tutorials\".\\n- \"🛠️ Tooling &amp; Ecosystem\".\\n- \"💬 Community &amp; Discussion\".\\n- \"👤 From the Core Team &amp; Community\" — people\u002Fcompany news.\\n- A short friendly sign-off.\\n\\nEvery item must be a markdown link to its real URL. Keep summaries tight and developer-focused. Omit any empty section. Output ONLY the markdown newsletter.`, { phase: 'Write' }, ) return { newsletter, itemCount: flatItems.length, curated }","2026-05-28T20:00:03.329Z","019e702c-2ae7-7659-a2b2-f994793c0dea","https:\u002F\u002Falexop.dev\u002Fposts\u002Fclaude-code-workflows-deterministic-multi-agent-orchestration\u002Findex.png","2026-05-28T00:00:00.000Z","claude-code-workflows-deterministic-multi-agent-orchestration","The article explores Claude Code's new workflows feature, which allows for deterministic orchestration of multi-agent tasks. By building a workflow to aggregate information from the Vue and Nuxt ecosystem, the author demonstrates how workflows can efficiently manage complex tasks through parallel processing and structured control flow. This hands-on approach highlights the practical applications and benefits of using Claude Code's orchestration capabilities.","Claude Code Workflows: Deterministic Multi-Agent Orchestration","2026-05-28T20:00:12.948Z","https:\u002F\u002Falexop.dev\u002Fposts\u002Fclaude-code-workflows-deterministic-orchestration\u002F","b15c964848800d569bbde2a08be74221a0132ff8a55b66063b640550f303d835",[57,58,61,64],{"color":29,"id":30,"name":31,"slug":31},{"color":29,"id":59,"name":60,"slug":60},"019d6bd8-fad3-70a9-a74d-ab96e3a2f45d","nuxt",{"color":29,"id":62,"name":63,"slug":63},"019e129e-3490-7739-a218-5454a8c15d6e","workflow",{"color":29,"id":65,"name":66,"slug":66},"019e702c-50d7-74eb-8849-8b1cebcf411e","orchestration",{"content":68,"createdAt":69,"id":70,"image":71,"isAffiliate":18,"isPublished":19,"publishedAt":72,"slug":73,"sourceId":6,"sourceName":7,"sourceType":9,"summary":74,"title":75,"updatedAt":76,"url":77,"urlHash":78,"tags":79},"An agent writes most of my frontend code now. I review what it produces and tighten the architecture where it overreaches. That changes what a quality pipeline is for. You used to write tests and types so the next person on the file stayed sane. Now you write them so the agent can check its own work. Give it more ways to verify a change (types, lint, unit, component, real browser, a11y, bundle budget) and it finishes more of the ticket on its own. A red check tells it what to try next. Frontend has also grown more complicated since 2024. SSR, streaming, partial prerendering, server components, edge runtimes. Each adds a place where a change can break silently. “TypeScript plus a couple of unit tests” no longer covers it. A quality pipeline is the set of checks that run on every change (locally, on commit, in CI) to give layered confidence the change is correct, accessible, performant, and safe to ship. A testing strategy is the part of that pipeline that asserts behaviour: what the app should do, at which level, at what cost. Plan them as one system. The pipeline decides when checks run; the strategy decides which checks are worth running. Design them together so that: Each check has a clear job and runs at the cheapest stage where it can catch the problem. The feedback loop is short enough that no developer or agent skips ahead. The same checks run on a contributor’s laptop, in an agent’s sandbox, and on the CI runner. The same pipeline shape applies whether you build with Next, Nuxt, Astro, SvelteKit, Remix, or a plain Vite app. The framework choice changes which adapter you import, nothing else. Background Frontend tooling consolidated between 2023 and 2026. Vite became the default dev\u002Fbuild engine across the major frameworks. Vitest replaced Jest. Playwright became the default for E2E. ESLint adopted flat config; Biome and Oxlint emerged as much faster alternatives in Rust. TypeScript strict mode became table stakes. Renovate replaced Dependabot. In March 2026, VoidZero shipped Vite+ as the open-source culmination of that trend: one CLI that wraps Vite, Rolldown, Vitest, Oxlint, Oxfmt, and Tsdown. A modern quality pipeline’s pieces look the same regardless of framework, so I’ll describe the concept first and my stack second. My default stack For new frontend projects in 2026 I reach for Vite+ instead of wiring the toolchain by hand. Vite+ (viteplus.dev) is the unified toolchain from VoidZero, Evan You’s company. It bundles Vite, Rolldown, Vitest, Oxlint, Oxfmt, and Tsdown behind a single CLI (vp dev, vp check, vp test, vp build) and one config file. The alpha shipped open source under MIT. If you adopt the pieces one at a time, the swaps I would make are: Old default What I use Why ESLint Oxlint ~50× faster, fast enough to run on every keystroke Prettier Oxfmt ~30× faster, Prettier-compatible defaults Jest Vitest ESM-native, browser mode, same matchers webpack Vite + Rolldown ~40× faster production builds four separate configs vp check \u002F vp test \u002F vp build one CLI, one config Each piece holds up on its own. I have shipped Vitest and Oxlint in production for some time; swapping Prettier for Oxfmt and webpack for Rolldown took a day in the projects I tried. Vite+ removes the integration cost that kept teams on the older stack. The layers Think of the pipeline as concentric layers, each cheaper and faster than the one outside it. Run cheap checks first. Save the expensive ones for the things only they can catch. 1. Type safety Type safety is your first line of defence. Run your framework’s type checker in CI on every PR. Treat any new type error as a build failure. If you use TypeScript, that means tsc --noEmit (or your framework’s wrapper around it; most frameworks ship one to handle their template syntax and project references). If you don’t use TypeScript yet, adopting it is the highest-leverage change you can make. Validate untyped boundaries with a schema library (Zod, Valibot, ArkType). Parse anywhere data crosses a boundary: route params, API responses, env vars, form input. TypeScript trusts the types you write; schemas check that the data matches them at runtime. With runtime parsing in place you stop reaching for as. See why as is a shortcut to avoid. 2. Lint and format Catches style and a wide class of bugs (unused vars, unsafe any, missing deps in effects) without running the code. The conventional choice is ESLint flat config plus typescript-eslint and your framework’s plugin. The 2026 alternative is Oxlint (Rust-based, ~50× faster) paired with Oxfmt for formatting, or Biome for a single-binary lint+format combo. The trade-off: Oxlint and Biome have smaller rule sets than ESLint’s mature ecosystem, but they cover most of the high-value cases and are fast enough to run on every keystroke. For a working setup that uses Oxlint as a fast first pass and keeps ESLint for the rules Oxlint doesn’t yet cover, see my opinionated ESLint setup for Vue projects. Add these two rule families regardless of which linter you pick. They catch bugs the type system misses: eslint-plugin-regexp – ~60 correctness rules for regular expressions. Cheap to add, catches real bugs. @e18e\u002Feslint-plugin – small performance lints (e.g., prefer Set.has over Array.includes) that compound across a codebase. 3. Unit tests For pure functions, hooks, stores, and utilities. Cheap, fast, and where most logic should live. Tool: Vitest. Run on every save in watch mode; run all of them in CI. Aim for high coverage of pure modules; don’t chase coverage on UI glue. For Vue, see my guide to testing Vue composables with Vitest. 4. Component tests For components in isolation, with a real DOM and real user interactions. The biggest win in 2026 is Vitest browser mode: your component tests run in a real Chromium via Playwright instead of jsdom. Hover states, focus, layout, intersection observers, and scroll behaviour all work as they do in production. Pair this with @testing-library\u002F* for whichever framework you use; accessibility assertions on each mounted component live in layer 8 below. For a deeper walkthrough of how this fits into a full testing pyramid, see my Vue 3 testing pyramid guide. 5. API mocking Hard-coded fixtures go stale. Tests that hit a real backend are flaky. Mock at the network layer once and reuse the same handlers everywhere. Tool: MSW (Mock Service Worker). It intercepts fetch, XHR, and GraphQL with a service worker in the browser and a request interceptor in Node, so the same handler definitions work in Vitest, Vitest browser mode, Playwright, and the dev server. Define handlers once in src\u002Fmocks\u002Fhandlers.ts; load them in your test setup and (optionally) in the dev server for offline-first development. Combined with Zod (or Valibot\u002FArkType) schemas at the same boundary, you get mocks that are typed, schema-validated, and shared across every layer that hits the network. One source of truth instead of three drifting fixture folders. 6. Contract testing The mocks in layer 5 are only as good as the assumptions you bake into them. If the backend renames a field or changes a status code without telling you, every green unit and component test still passes while production breaks. Contract testing closes that gap by tying the mock to a verifiable artefact that the provider checks against. There are three styles, and they fit different team setups. Consumer-driven contracts (Pact). The frontend writes a test that records the requests it makes and the responses it expects. Pact generates a JSON contract and publishes it to a broker (the open-source Pact Broker, or hosted PactFlow). The provider runs its real test suite against that contract; if it satisfies every recorded interaction, both sides can deploy. Pact has libraries for JS\u002FTS, JVM, .NET, Go, Rust, Python, Ruby, PHP, and Swift, so the same broker spans a polyglot estate. Best when you control both ends of the wire and want the consumer to drive the schema. Provider-driven \u002F OpenAPI-based. The provider publishes an OpenAPI spec and the contract is the spec. Consumers validate their requests and assertions against it with Schemathesis (property-based fuzzing of every operation), Dredd (replays example requests from the spec against the running provider), or Spectral (lints the spec itself). Best when the provider already maintains an OAS and you don’t want to add Pact on their side. Bi-directional contracts (PactFlow). The consumer publishes a Pact contract; the provider publishes its OpenAPI spec; PactFlow proves the two are compatible without the provider having to run consumer-supplied tests. Best when consumers want consumer-driven semantics but the provider team won’t (or can’t) run Pact verification themselves. What you get for the work: Independent deploys. A contract gate replaces “is the matching E2E green?” with “does the provider satisfy every consumer’s contract?”. Consumer and provider can ship on different cadences without coordinating a release train. Faster than E2E. Verifying a contract is a unit test for the boundary; E2E spins up the real services. You catch the same class of bug an order of magnitude sooner. Catches drift the linter can’t. A field renamed on the backend fails the contract before MSW handlers or Playwright flows would notice. Skip this layer if you own both services and ship them as one unit. E2E covers the same boundary in that case, and the contract overhead doesn’t pay off. Add it the moment consumer and provider deploy on different cadences, you don’t own the provider, or a single backend serves multiple frontends that all need to keep working. For a deep dive, Contract Testing in Action (Marie Cruz &amp; Lewis Prescott, Manning) walks through Pact, bi-directional contracts, and how to introduce the practice without stalling delivery. 7. End-to-end tests For critical user journeys across real pages: signup, checkout, the one or two flows that must never break. Keep the suite small. E2E is expensive. Tool: Playwright. Run against a built preview, not the dev server. Two assertions worth wiring into a custom fixture, regardless of framework, because they catch silent regressions: Hydration mismatches. Listen for hydration warnings on console and fail the test if any appear. SSR\u002FCSR drift is one of the most common silent regressions in modern frameworks. I wrote a dedicated post on catching hydration errors in Playwright tests with a reusable fixture. CSP violations. Listen for securitypolicyviolation events. If your CSP is real, this turns every E2E run into a CSP regression test. 8. Accessibility Accessibility cuts across lint, component, E2E, and preview. Treat it as a single discipline and check the same WCAG rule set at every cheap-enough stage. Lint. eslint-plugin-jsx-a11y (React\u002FJSX), eslint-plugin-vuejs-accessibility (Vue), eslint-plugin-astro — catch missing alt, role mismatches, and other static violations before tests run. Component. axe-core via jest-axe for jsdom, or @axe-core\u002Fplaywright in Vitest browser mode. Assert no violations on every mounted component, and add a meta-test that fails if any component test lacks an a11y assertion so the practice doesn’t slide. E2E. @axe-core\u002Fplaywright on each critical journey — same engine as the component layer, but on the real composed page where many violations only appear once everything is wired together. Preview. Lighthouse’s accessibility category (run as part of layer 10) or Pa11y CI on a list of routes for a dedicated, auditable report. Manual. Storybook’s a11y addon, keyboard-only walkthroughs of new flows, and screen-reader spot-checks. Automated tools catch roughly 30% of WCAG issues; the rest needs a human. This is no longer optional in the EU: the European Accessibility Act took effect in mid-2025, so most B2C and many B2B products operating in EU markets are now legally required to meet WCAG 2.1 AA equivalence. For a framework-specific checklist, see my Vue accessibility blueprint. 9. Visual regression Catches unintended UI drift that unit and E2E tests miss. Chromatic (hosted, Storybook-native) or Playwright screenshots + a diff tool like Lost Pixel for self-hosted. For a Vitest-native approach, see how to do visual regression testing in Vue with Vitest. onlyChanged: true keeps it cheap: only re-snapshot stories whose dependencies changed. Gate on PR; review diffs as part of code review. 10. Performance and bundle size Performance regressions are silent unless you measure them. Lighthouse CI on a preview deployment. Run it against both a light and dark color scheme; contrast regressions show up only in one. size-limit or your framework’s bundle analyzer on PR for bundle deltas. Set explicit budgets and fail the build when they’re exceeded. Lab measurements catch regressions before merge. To see what real users experience, and to find bottlenecks while you’re writing the code, see layer 15 below. 11. Dead code and dependency hygiene Unused code is a tax on every other check. Knip to find unused files, exports, and dependencies. Configure per-workspace if you have a monorepo. Renovate for automated dependency updates with grouping and a sane schedule. OSV-Scanner for vulnerabilities and Gitleaks for secrets, gated to high-severity only to avoid alert fatigue. Generate an SBOM (Software Bill of Materials) on every build with Syft, Trivy, or cdxgen, in CycloneDX or SPDX format. This is shifting from “nice to have” to “regulated requirement” in 2026 (EU CRA, US executive orders), and it’s the same artefact your security team uses to answer customer vulnerability questionnaires. 12. Internationalisation drift If you ship in more than one language, untranslated strings slip through. A mature i18n library plus a drift checker in CI catches them. i18n libraries – i18next, vue-i18n, FormatJS \u002F react-intl, and Lingui all expose a missing-key handler you can fail the build on, plus extractor CLIs that refuse to ship if a string has no translation entry. Lint your source for hardcoded strings. ESLint has eslint-plugin-i18next and @intlify\u002Feslint-plugin-vue-i18n to flag bare strings in JSX\u002Ftemplates and unused or missing keys. Oxlint doesn’t yet ship i18n-specific rules, so run it as the fast first pass and keep these ESLint plugins for the i18n layer. Lunaria compares each locale against a source locale and reports missing or stale keys. It works with any project that has translation files; you can publish a public status dashboard from the same data. 13. Preview deployments The cheapest way to enable manual review and to give E2E, Lighthouse, and visual-regression checks something realistic to run against. Vercel, Netlify, or Cloudflare Pages will give you a unique URL per PR for free. Wire your downstream checks to that URL. 14. Automated code review In 2026, AI code review is a standard pipeline stage. It runs before any human reviewer touches the PR and catches issues the layers above miss: logic mistakes, missing edge cases, security smells, and the small inconsistencies that lint rules can’t express. CodeRabbit, Greptile, and Vercel Agent are the main options. Recent benchmarks put Greptile’s bug-catch rate around 82% versus CodeRabbit’s ~44%, but Greptile produces more false positives and runs slower; CodeRabbit covers more git platforms. Have it run alongside specialist scanners (secrets, vulnerabilities, workflow lint, shell\u002Fyaml lint) so a single bot comment summarises every machine-checkable concern on the PR. Pause the bot on Renovate \u002F Dependabot PRs to avoid noise on mechanical updates. Treat the AI reviewer as a high-recall first pass that reduces human review without replacing it. If you want to add an AI agent that goes one step further and exercises the app in a real browser, see how I run automated QA with Claude Code, Agent Browser, and GitHub Actions. 15. Runtime observability Layers 1–13 give you confidence at merge time. Once a change is in production, and while you’re writing it, you also want a live view of what the app is doing. The same instrumentation answers both questions. Use OpenTelemetry as the SDK. It’s the only vendor-neutral option, and the JS ecosystem caught up in 2025–2026: stable web SDK, official auto-instrumentations for document-load, fetch, xhr, and user-interaction, and OTLP support in every backend that matters. Browser SDK. @opentelemetry\u002Fsdk-trace-web plus the auto-instrumentations emits OTLP\u002FHTTP. Wrap web-vitals into OTel metrics so LCP\u002FINP\u002FCLS land on the same backend as the trace that produced them. One pipe instead of two. SSR \u002F edge. Next, Nuxt, SvelteKit, and Astro all expose OTel hooks. Set a single service.name resource attribute and one request stitches together: edge → SSR → hydration → client interaction, all in one trace. One collector, two backends. Run an OpenTelemetry Collector with two exporters. In dev, point it at Jaeger or Grafana Tempo running via docker-compose; open localhost:16686 and you can watch every fetch, render, and hydration span as you click through the app. In prod, swap the exporter to Honeycomb, Grafana Cloud, Dash0, or Sentry’s OTel ingest. Same SDK, same instrumentations, different OTLP endpoint. Sample, or pay. ParentBased(TraceIdRatioBased(0.05)) in prod, AlwaysOn in dev. Tail-sample at the collector to keep the slow and error traces and drop the rest, so the signal that matters survives without renting cloud storage for every render. The dev-time payoff is the part most teams underuse. The next time someone asks why a page is slow on a real device, you already have the trace from when they loaded it. Where each layer runs Same layers, different stages. Pick the cheapest stage where each check can catch the problem. Stage What runs Editor Type checker LSP, linter, Vitest watch Pre-commit Format and lint on staged files only CI on PR Typecheck, full lint, unit, component, contract verify, build, knip, size-limit, AI review CI on preview URL E2E, accessibility (axe + Lighthouse), visual regression Post-merge \u002F nightly Full E2E matrix, dependency updates, security scans, SBOM publish Dev server \u002F production OpenTelemetry traces and metrics: live in dev, sampled in prod For wiring local hooks themselves, Lefthook has become the default modern alternative to Husky: a single Go binary, declarative YAML config, and parallel execution of lint\u002Fformat\u002Ftest commands on staged files. Commit a lefthook.yml to the repo, run lefthook install once, and contributors get the same hook setup automatically. Pair it with lint-staged (or Lefthook’s built-in {staged_files} substitution) so pre-commit only runs against the files that changed. That fast-check pattern keeps the hook under a couple of seconds. Git 2.54 added config-based hooks, which means a small project no longer needs an external hook manager at all. You define hooks in .gitconfig instead of as scripts under .git\u002Fhooks: [hook \"linter\"] event = pre-commit command = pnpm exec oxlint --staged [hook \"format\"] event = pre-commit command = pnpm exec oxfmt --check Multiple hooks per event run in order. Disable a single hook with hook.&lt;name&gt;.enabled = false (useful for opting one repo out of a system-wide config), and list the active ones with git hook list pre-commit. The traditional .git\u002Fhooks\u002F* scripts still run last, so existing setups keep working. For a small project this covers most of what Lefthook does without an extra binary. Lefthook still has the edge for parallel execution and staged-file substitution. One valid alternative skips commit hooks and runs everything server-side in CI as required status checks. You trade a slower red-CI feedback loop for never blocking a contributor with a flaky local hook. If GitHub Actions is the CI you’re wiring this onto, GitHub Actions in Action (Kaufmann, Bos &amp; de Vries, Manning) covers workflow design, reusable actions, matrix builds, secrets, and self-hosted runners. It pays off once you outgrow the default templates and start wiring the layers above into shared workflows. What this gets you Regressions caught before merge. A typed schema at the boundary plus a Playwright check on the critical path catches more shipped bugs than any single layer alone. Refactors get safer. Strict types and a healthy unit and component test suite let you change internals without breaking surface behaviour. Onboarding gets shorter. A new contributor can run one command, see green, and trust that CI will tell them if they break something. No one becomes the bottleneck. The pipeline enforces the standard for accessibility, performance, and i18n, so quality stops riding on a single contributor. Picking your testing shape The layers above tell you what to run. They don’t tell you how much weight to give each one. A solo dev shipping a Vite app needs a different mix than a fifty-engineer team coordinating across a dozen services. The industry argues about this in shapes. The classical Pyramid (Mike Cohn) puts most of the weight on unit tests and very little on E2E. Kent C. Dodds’ Trophy moves the weight to integration tests because frontend bugs live at the component-interaction layer. Spotify’s Honeycomb pushes weight onto integrated and contract tests because in a microservices world, isolation tests prove little and full E2E is brittle. The Ice-cream cone is the anti-pattern you end up with by accident: lots of slow E2E on top, almost nothing underneath. The right answer is “it depends,” but you can be precise about what it depends on. As web.dev puts it in Pyramid or Crab, “the testing strategy that’s right for your team is unique to your project’s context”. Pactflow, from the contract-testing camp, goes further: at scale, full E2E is a tax with diminishing returns once teams and services multiply. The four inputs that change the answer most: Team size. Six developers can keep a real E2E suite green together; sixty cannot. Coordination cost is what kills E2E suites at scale. Backend control. If you don’t own the backend, contract testing goes from “nice” to “the only way you can change anything safely”. Number of services in the flow. Each new service multiplies the surface that has to be set up, seeded, and reset for an E2E run. Deployment cadence. A daily-merge team can’t afford a flaky twenty-minute suite; a quarterly-release team can. Pick yours and the shape that fits will appear: The same pyramid that helps one team can block another from shipping. Pick the shape that fits your constraints. Picking your battles You don’t need every layer on day one. A reasonable order to add them: TypeScript strict + a fast linter + a formatter, wired into Lefthook so format and lint run on staged files at commit time. Vitest for utilities, run on PR. MSW handlers for any module that hits the network, shared between tests and the dev server. Playwright for the single most important user journey, with hydration and CSP listeners wired into a shared fixture. Contract testing the moment consumer and provider deploy on different cadences. Pact if you control both sides, OpenAPI validation if the provider already publishes a spec. Preview deployments and Lighthouse CI (light and dark). OpenTelemetry traces and metrics. Point the SDK at a local Jaeger via docker-compose the moment a “why is this slow?” question takes more than five minutes to answer. Same SDK in prod (different OTLP endpoint) once you have real users. Storybook + visual regression once the design system stabilises. Accessibility audits in component tests and E2E. Knip and bundle-size budgets once the codebase has weight. i18n drift checking once you ship a second locale. AI code review and SBOM generation once the project has external stakeholders to answer to: reviewers, customers, or compliance. Each layer should pay for itself in caught regressions or saved review time. Remove the ones that don’t. Supply chain defaults The pipeline above catches the bugs you write. None of it stops a compromised dependency from running code on your laptop. The 2025–2026 wave of npm attacks (Shai-Hulud, the Rspack postinstall cryptominer, the axios 1.14.1 hijack) made the package manager’s defaults a real part of your security posture. I use pnpm on every new project. Three of its settings do most of the work. 1. Lifecycle scripts blocked by default. Since pnpm 10, preinstall and postinstall scripts in dependencies do not run on pnpm install. You opt specific packages in via pnpm.onlyBuiltDependencies in package.json. Most historical supply chain payloads shipped through postinstall, so this default removes one of the biggest attack vectors. 2. minimumReleaseAge. A pnpm 10.16+ setting that refuses to resolve a published version until it is at least N minutes old. Set it to 1440 (one day) or 10080 (one week) in pnpm-workspace.yaml. Most compromised packages get detected and unpublished within hours, so a 24-hour delay covers the common published-and-pulled incidents. pnpm 11 makes one day the default. Use minimumReleaseAgeExclude for the few internal or first-party packages you need to install the moment they ship. 3. blockExoticSubdeps. Refuses transitive dependencies pinned to git repositories or tarball URLs. Closes a common path for typo-squatting and dependency confusion. A minimal pnpm-workspace.yaml for a new project: minimumReleaseAge: 1440 blockExoticSubdeps: true onlyBuiltDependencies: - esbuild - sharp Pair this with OSV-Scanner and Gitleaks in CI (layer 11 of the pipeline) and you cover both the install-time and the audit-time sides of supply chain security. Related resources If you want to read more or start implementing this: Tools Vite+ – unified toolchain from VoidZero (Vite, Rolldown, Vitest, Oxlint, Oxfmt, Tsdown) pnpm supply chain security – the full list of defaults and settings discussed above Vitest – including browser mode Playwright MSW – network-level API mocking for browser and Node Pact and PactFlow – consumer-driven and bi-directional contract testing Schemathesis, Dredd, Spectral – OpenAPI-based contract testing and linting Lefthook – fast Git hooks manager (modern Husky alternative) Storybook Knip Oxlint Biome Lighthouse CI Lunaria – i18n drift detection size-limit axe-core Syft – SBOM generation CodeRabbit, Greptile – AI code review Reference The Practical Test Pyramid – Ham Vocke’s canonical write-up of Mike Cohn’s pyramid. The Testing Trophy – Kent C. Dodds’ model for where to put the weight of your tests. Testing of Microservices (Honeycomb) – Spotify’s case for integrated tests over isolated unit tests in a microservices world. Pyramid or Crab? Find a testing strategy that fits – web.dev on choosing a shape for your context. Proving E2E tests are a scam – Pactflow’s contract-first counter-position. Contract Testing in Action – Marie Cruz &amp; Lewis Prescott (Manning) on consumer-driven and bi-directional contracts in practice. GitHub Actions in Action – Kaufmann, Bos &amp; de Vries (Manning) on workflows, reusable actions, matrix builds, and self-hosted runners. Frontend testing guide: 10 essential rules for naming tests","2026-04-25T18:07:54.197Z","019dc5d3-a141-771d-be4c-4fce2ee29fe6","https:\u002F\u002Falexop.dev\u002Fposts\u002Fa-modern-quality-pipeline-and-testing-strategy-for-frontend-projects\u002Findex.png","2026-04-25T00:00:00.000Z","a-modern-quality-pipeline-and-testing-strategy-for-frontend-projects","The article discusses the evolution of frontend quality pipelines and testing strategies, emphasizing the need for comprehensive checks to ensure code quality in increasingly complex environments. It highlights the importance of integrating various tools like Vite, Vitest, and Oxlint into a unified pipeline that can adapt to different frameworks, including Nuxt. The author advocates for a modern approach where the pipeline and testing strategy are designed together to enhance developer efficiency and code reliability.","A Modern Quality Pipeline and Testing Strategy for Frontend Projects","2026-04-25T18:08:02.112Z","https:\u002F\u002Falexop.dev\u002Fposts\u002Fmodern-frontend-quality-pipeline\u002F","b4cf663ff2cb6932a7923d040313f475d0f879aa91eb3f8a52678d36b150b7c9",[80,83,86,89,92],{"color":29,"id":81,"name":82,"slug":82},"019d9cf0-c9f8-7411-beed-02265d8271db","vite",{"color":29,"id":84,"name":85,"slug":85},"019dc5d3-c054-70e2-bca4-08e2a2b9a096","vitest",{"color":29,"id":87,"name":88,"slug":88},"019d70de-3c07-76bf-9688-9619e1b0d427","testing",{"color":29,"id":90,"name":91,"slug":91},"019d6bd8-ca89-735e-a52a-ee53a80a77a9","ssr",{"color":29,"id":59,"name":60,"slug":60},{"content":94,"createdAt":95,"id":96,"image":97,"isAffiliate":18,"isPublished":19,"publishedAt":98,"slug":99,"sourceId":6,"sourceName":7,"sourceType":9,"summary":100,"title":101,"updatedAt":102,"url":103,"urlHash":104,"tags":105},"Your E2E tests pass. The page loads, buttons work. But open the browser console: Hydration failed because the server rendered HTML didn't match the client. This is a hydration mismatch. The server sent one thing and the client replaced it with something else. The page still works, so you don’t notice. Your tests don’t check for it, so they pass. What are SSR and hydration? SSR (server-side rendering) means the server generates HTML and sends it to the browser before JavaScript loads. Users see content before client code boots, and search engines can index it. Astro and Nuxt build on this model. Hydration is the next step: client JavaScript takes over the server-rendered HTML, attaching event handlers and state to the existing markup. The contract: the first client render must match what the server sent. When it does not match, the framework discards the server HTML and re-renders on the client. That re-render is a hydration mismatch. Common causes Anything that produces different HTML on client and server: Reading localStorage or window.matchMedia() during render Calling new Date() or Math.random() during render Formatting dates or numbers differently across server and client Rendering conditional branches based on browser-only state Theme toggles and locale formatting cause most of them. If you use Vue with SSR, the window is not defined error comes from the same root cause. VueUse has a pattern for it: A real bug I found I was working on an Astro page and my theme hook was reading browser state during the first render: function getInitialTheme(): Theme { const stored = localStorage.getItem(SITE.themeStorageKey); if (stored === \"light\" || stored === \"dark\") return stored; return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\"; } export function useTheme() { const [theme, setTheme] = useState&lt;Theme&gt;(getInitialTheme); } The server defaulted to dark, but the browser picked light. React saw the mismatch, logged a hydration warning, and re-rendered from scratch. The page still worked, the button still existed. Normal E2E tests passed. The fix: start with a deterministic value, resolve browser state after mount. export function useTheme() { const [theme, setTheme] = useState&lt;Theme&gt;(\"dark\"); const [mounted, setMounted] = useState(false); useEffect(() =&gt; { const preferredTheme = getPreferredTheme(); document.documentElement.classList.toggle(\"dark\", preferredTheme === \"dark\"); setTheme(preferredTheme); setMounted(true); }, []); useEffect(() =&gt; { if (!mounted) return; const root = document.documentElement; root.classList.toggle(\"dark\", theme === \"dark\"); localStorage.setItem(SITE.themeStorageKey, theme); }, [mounted, theme]); return { theme, setTheme, toggleTheme: () =&gt; setTheme((t) =&gt; (t === \"dark\" ? \"light\" : \"dark\")) }; } The core idea Listen to the browser console during a Playwright test. If a hydration warning appears, fail the test. React and Vue log hydration mismatches to the console. You don’t check the console during automated tests, so this fixture does. The fixture Fixtures are Playwright's way of setting up and tearing down what each test needs. Built-in fixtures like `page` and `browser` come for free. You create custom ones with `base.extend()`. Each fixture runs when a test requests it and gets cleaned up afterward. The fixture below injects `hydrationErrors` and `runtimeErrors` into every test that asks for them. I first saw this approach in the npmx.dev open source project and adapted it for my Astro site. My version covers React and Vue hydration strings and catches uncaught runtime exceptions: const HYDRATION_ERROR_PATTERNS = [ \u002Fhydration failed because the server rendered html didn't match the client\u002Fi, \u002Fhydration completed but contains mismatches\u002Fi, \u002Fhydration text content mismatch\u002Fi, \u002Fhydration node mismatch\u002Fi, \u002Fhydration attribute mismatch\u002Fi, ]; function isHydrationError(text: string): boolean { return HYDRATION_ERROR_PATTERNS.some((pattern) =&gt; pattern.test(text)); } function toConsoleText(message: ConsoleMessage): string { return message.text().trim(); } export const test = base.extend&lt;{ hydrationErrors: string[]; runtimeErrors: string[]; }&gt;({ hydrationErrors: async ({ page }, use) =&gt; { const hydrationErrors: string[] = []; const handleConsole = (message: ConsoleMessage) =&gt; { const text = toConsoleText(message); if (isHydrationError(text)) { hydrationErrors.push(text); } }; page.on(\"console\", handleConsole); await use(hydrationErrors); page.off(\"console\", handleConsole); }, runtimeErrors: async ({ page }, use) =&gt; { const runtimeErrors: string[] = []; const handleConsole = (message: ConsoleMessage) =&gt; { const text = toConsoleText(message); if (message.type() === \"error\" &amp;&amp; text.length &gt; 0 &amp;&amp; !isHydrationError(text)) { runtimeErrors.push(text); } }; const handlePageError = (error: Error) =&gt; { runtimeErrors.push(error.message); }; page.on(\"console\", handleConsole); page.on(\"pageerror\", handlePageError); await use(runtimeErrors); page.off(\"console\", handleConsole); page.off(\"pageerror\", handlePageError); }, }); export { expect }; Drop this into test\u002Fe2e\u002Ftest-utils.ts and import from there instead of @playwright\u002Ftest. Related: a full AI-driven QA workflow with Playwright: Using it test(\"home page hydrates cleanly\", async ({ page, hydrationErrors, runtimeErrors }) =&gt; { await page.goto(\"\u002F\", { waitUntil: \"domcontentloaded\" }); await expect(page.getByRole(\"heading\", { name: \"Home\" })).toBeVisible(); expect(hydrationErrors).toEqual([]); expect(runtimeErrors).toEqual([]); }); Start with your homepage. Add one interactive route, then one with a theme toggle or client-only widget. That surfaces most bugs. How npmx.dev does it at scale The npmx.dev project tests hydration correctness for every combination of user settings across every page, around 48 checks from a single fixture. They inject localStorage values via Playwright’s page.addInitScript() before navigation, simulating a returning user with saved preferences. Returning users with non-default settings trigger most hydration mismatches. const PAGES = [\"\u002F\", \"\u002Fabout\", \"\u002Fsettings\", \"\u002Fcompare\", \"\u002Fsearch\", \"\u002Fpackage\u002Fnuxt\"]; test.describe(\"color mode: dark\", () =&gt; { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) =&gt; { await injectLocalStorage(pw, { \"npmx-color-mode\": \"dark\" }); await goto(page, { waitUntil: \"hydration\" }); expect(hydrationErrors).toEqual([]); }); } }); async function injectLocalStorage(page: Page, entries: Record&lt;string, string&gt;) { await page.addInitScript((e: Record&lt;string, string&gt;) =&gt; { for (const [key, value] of Object.entries(e)) { localStorage.setItem(key, value); } }, entries); } They repeat this for every setting type, locale, accent color, background theme, package manager, relative dates, each with a non-default value. If any combination causes a hydration mismatch on any page, the test fails. Their fixture uses Vue-specific error strings (\"Hydration completed but contains mismatches\") while mine uses React patterns. The approach is the same, only the strings you match against change. More on how E2E tests relate to unit and integration tests: If you ship an SSR app and do not check for hydration errors in your browser tests, you have one in production right now.","2026-04-09T06:11:38.114Z","019d70de-1de7-70ea-8344-aded9a900a5c","https:\u002F\u002Falexop.dev\u002Fposts\u002Fhow-to-catch-hydration-errors-in-playwright-tests-astro-nuxt-react-ssr\u002Findex.png","2026-04-06T00:00:00.000Z","how-to-catch-hydration-errors-in-playwright-tests-astro-nuxt-react-ssr","The article discusses how to identify and address hydration errors in Playwright tests, particularly in applications using SSR with frameworks like Nuxt and Astro. It explains the concept of hydration mismatches, common causes, and offers solutions to ensure consistent rendering between server and client. The focus is on maintaining a deterministic initial state to prevent hydration warnings during testing.","How to Catch Hydration Errors in Playwright Tests (Astro, Nuxt, React SSR)","2026-04-09T06:11:45.745Z","https:\u002F\u002Falexop.dev\u002Fposts\u002Fcatch-hydration-errors-playwright-tests\u002F","a61a606d501f4750cf6e2136ea2210d1a0550f673021e364846f1ce1953dd9cd",[106,107,108,109],{"color":29,"id":59,"name":60,"slug":60},{"color":29,"id":90,"name":91,"slug":91},{"color":29,"id":87,"name":88,"slug":88},{"color":29,"id":110,"name":111,"slug":111},"019d6bd8-ca26-775c-b9b5-c3439dbe5789","performance",1,20]