Getting Paid Should Not Be a Geopolitical Nightmare: My NOWPayments Integration Story

The Problem We Were Actually Solving

I was tasked with integrating a payment gateway into our digital goods store, which catered to a global customer base. The challenge was that our company was based in a country with strict financial regulations, making it difficult to find a reliable payment processor. Many popular payment gateways had restrictions or outright bans on businesses from our region, which significantly limited our options. After researching various alternatives, we decided to explore NOWPayments, a crypto-based payment solution that claimed to have fewer geographical restrictions.

What We Tried First (And Why It Failed)

Initially, we attempted to use Stripe, a well-established payment gateway, but our application was rejected due to our company’s country of origin. We also tried to use PayPal, but their restrictions on our region made it impossible to receive payments. These rejections led us to consider more unconventional solutions, such as using a third-party payment aggregator or creating our own custom payment processing system. However, these alternatives introduced significant technical and financial risks, which we were not willing to take on. It was during this time that we discovered NOWPayments and decided to give it a try.

The Architecture Decision

After evaluating NOWPayments’ API documentation and testing their sandbox environment, we decided to integrate their solution into our digital goods store. The main tradeoff was that we had to adapt our store’s checkout process to accommodate the crypto-based payment flow, which added some complexity to our codebase. We used the NOWPayments API to generate payment invoices and handle transaction callbacks, which required careful error handling and logging to ensure reliable payment processing. We also had to implement a custom solution for handling cryptocurrency price volatility, as our store’s pricing was denominated in fiat currency.

What The Numbers Said After

The integration with NOWPayments took approximately six weeks to complete, including testing and deployment. Once live, we monitored the payment processing metrics closely, and the results were promising. Our payment success rate increased by 25% compared to the previous quarter, and we saw a significant reduction in failed transactions due to geographical restrictions. The average transaction processing time was around 2-3 seconds, which was acceptable for our use case. However, we did experience some issues with cryptocurrency price fluctuations, which affected our revenue by about 5% due to exchange rate discrepancies. To mitigate this, we implemented a pricing update mechanism that adjusted our store’s prices in real-time based on the current cryptocurrency exchange rates.

What I Would Do Differently

In hindsight, I would have liked to explore more alternative payment solutions before settling on NOWPayments. While their crypto-based approach worked for us, it introduced some complexity and risks that we had to carefully manage. If I had to do it again, I would consider using a more traditional payment gateway with a stronger presence in our region, even if it meant establishing a subsidiary company or partnering with a local business to facilitate payments. Additionally, I would prioritize implementing a more robust pricing update mechanism from the outset to minimize the impact of cryptocurrency price volatility on our revenue. The experience taught me that getting paid should not be a geopolitical nightmare, and there are often creative solutions to be found in the unlikeliest of places, such as crypto-based payment gateways like NOWPayments.

Four Layers of Validation in Kubernetes with Claude Code

Earlier this year, Moltbook, a social network for AI agents, launched, trended, and became a cautionary tale within the same week. Security researchers at Wiz found a Supabase API key sitting in its client-side JavaScript, which was the database’s only access control, with no Row Level Security to narrow what that key could reach. The result: 1.5 million API tokens, 35,000 email addresses, and thousands of private messages exposed to anyone with a browser console.

Moltbook was a greenfield project with no review process. The same class of mistake is far more serious when AI-generated code lands inside applications that already have pre-existing users, real data, and an existing trust surface. That’s increasingly the reality: as organizations adopt AI coding agents, more and more AI-generated code is landing directly in production services that already hold credentials and personal details of your users. A recent survey found that 95% of developers don’t fully trust AI-generated code, while only 48% consistently review it before committing, yet it’s shipping regardless.

Static review tools catch only some classes of issues: common CVEs, dependency hygiene, style violations, deterministic anti-patterns. What they can’t see are things like the actual name of your Kubernetes Secret in this cluster, whether your auth middleware is wired into the right route in this service, whether the request a real user will send makes it through the new code path without breaking something downstream.

Closing that gap takes four independent layers: AI agent skills that shape what gets generated, commands that audit what was generated, integration tests that hit staging endpoints, routing traffic to your local code, and preview environments that let a human review the change against staging dependencies before merging.

Layer 1: Skills (passive, shaping what gets generated)

Most AI coding assistants let you write down rules that shape generation. The simplest mechanism is a config file that the assistant loads into context on every prompt: .cursorrules in Cursor, CLAUDE.md in Claude Code, .github/copilot-instructions.md in GitHub Copilot. Drop NEVER/ALWAYS rules in there and the AI follows them. The downside is that those files load on every prompt, even when you’re working on something unrelated, and every rule you add costs tokens whether or not it’s relevant.

Claude Code goes a step further with skills: structured rule sets that ship as plugins (a directory with a SKILL.md and supporting reference files). Each skill has a description, and the model pulls a skill into context only when your prompt matches what that skill is meant to cover. If a skill never gets matched against the prompt, it never gets loaded, and you don’t pay for the tokens it would consume. We’ve already shipped six skills for AI agents working with mirrord, and this post adds a seventh focused on validation: k8s-validation, an open-source set of NEVER/ALWAYS rules for code that runs inside a Kubernetes cluster.

The skill covers two halves: the cluster-level concerns (Secrets, RBAC, pod hardening, NetworkPolicies, supply chain, file handling) and the application-level concerns that determine whether the workload behaves correctly inside the cluster (HTTP and parameter handling, auth, output sanitisation, API contracts, env-var configuration, test coverage).

Before and after

Without the skill, the model has no way of knowing about things like Kubernetes Secrets already mounted as environment variables in your pod, auth decorators other handlers in your service already use, or PII-sanitization utilities your team has already built. So it does the obvious thing: hardcodes the API key, skips the auth check, and returns the LLM output directly. With the skill loaded, the following prompt (“add a /summarise endpoint that calls OpenAI’s API”) produces something like this:

import os
from openai import OpenAI
from utils.sanitize import filter_pii

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

@app.route('/summarise', methods=['POST'])
@require_auth
def summarise():
    text = request.json.get('text', '')
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": f"Summarise: {text}"}],
    )
    summary = response.choices[0].message.content
    return jsonify({"summary": filter_pii(summary)})

filter_pii here is a stand-in for any utility that strips personally identifiable information (names, SSNs, emails, etc.) out of free text. Different teams build this differently; the Microsoft Presidio library is one of the more common open-source starting points.

Three differences from what the model would produce without the skill: the key comes from an environment variable backed by a Kubernetes Secret, the endpoint sits behind @require_auth, and the LLM output runs through filter_pii before going back to the user.

What skills can’t do

Skills shape generation, but they don’t verify anything.

In the example above, the AI correctly followed the skill: it read the API key from the environment, applied @require_auth, and called filter_pii from utils.sanitize. But the skill has no way to verify that filter_pii actually works. If the utility in your codebase only strips email addresses and misses phone numbers, the skill can’t know that. A user document containing a phone number sails straight through the filter and into the response, and the code looks correct at every layer the skill can see.

Skills set a floor by preventing the obvious structural mistakes. They’re instructions to a model, not checks against reality.

Layer 2: Commands (active, checking what was generated)

Where skills shape what the model generates, commands check what already exists. They’re explicitly invoked by a developer, an agent, or a CI step, and they run a defined set of checks against the code in front of them.

The same install from Layer 1 also ships a slash command: /k8s-validation:audit. It scans your codebase for the same NEVER/ALWAYS rules the skill enforces during generation, traces data flow through handlers and queries, and classifies each finding by severity. Skills don’t always load (a vague prompt, or a quick edit to a file the model didn’t classify as Kubernetes-adjacent, and the rules never enter context). The audit is the backstop: it runs on the code regardless of how the code got written.

> /k8s-validation:audit content-api/

CRITICAL 2 | HIGH 4 | MEDIUM 1 | INFO 0

[CRITICAL] src/routes/summarise.py: Hardcoded OpenAI API key → Use os.environ
[CRITICAL] src/routes/download.py: User filename in path without sanitization → Use secure_filename()
[HIGH]     src/routes/summarise.py: No authentication middleware → Add @require_auth
[HIGH]     k8s/deployment.yaml: No SecurityContext defined → Add runAsNonRoot, drop ALL
[HIGH]     src/routes/summarise.py: Reads OPENAI_API_KEY but no manifest defines it → Add to deployment.yaml env block
[HIGH]     src/routes/summarise.py: New endpoint with no integration test → Add test in tests/integration/

Note that the output mixes security findings (hardcoded key, missing SecurityContext) with correctness findings, meaning “does the code do what was asked, given the rest of the system” (the Kubernetes deployment manifest doesn’t define the env var the code reads; the new endpoint shipped without an integration test). Both halves matter for AI-generated code.

Because the audit is a command you run rather than a rule the model loads, the same invocation works in three places: a developer runs it before opening a PR, an agent runs it as part of its own loop after generating code, and CI runs it as a merge gate. You can wire it into one, two, or all three.

## Validation Workflow
After generating or modifying any Kubernetes-related code, run `/k8s-validation:audit`
on the changed files. If any CRITICAL findings exist, fix them before proceeding.

What commands can’t do

The audit is still static analysis. It can find “you hardcoded a secret” or “you’re missing a SecurityContext,” but it can’t tell you whether your filter_pii regex actually catches the PII your users will send, or whether the environment variable you’re reading will resolve to a value in your staging cluster. Commands check the shape of the code, not the behavior.

Layer 3: Integration tests (runtime, proving it works)

Your team probably already has integration tests that hit your API endpoints, check response shapes, and verify that authentication rejects bad credentials. These tests encode what “correct behavior” actually means for your application.

The bottleneck is running them. Locally, you mock your database, your auth service, your message queue, and hope the mocks match reality. In CI, each cycle takes 5 to 10 minutes. For a human pushing a few times a day, it’s already frustrating enough. For an AI agent trying to fix a failing test, it’s a feedback loop far too slow to learn from: the agent burns tokens on every iteration, and the integration bugs only surface after the change has been written, pushed, and built.

mirrord changes this equation by letting your local process stand in for a deployed pod. Your local code gets the environment variables from the target pod, the same cluster-level files it has access to, and the same view of internal services. In steal mode, traffic destined for the targeted pod is intercepted and routed to your local process instead of whatever’s deployed in staging. Your existing integration tests, pointed at staging endpoints as usual, now run against your local code in seconds, not minutes.

The same pattern scales horizontally. Because mirrord can split a single pod’s incoming traffic between many local processes using header-based filters, multiple agents (or developers) can iterate against the same staging cluster simultaneously, each one routing its own slice of the traffic to its own local code. One staging environment, many concurrent agents, real downstream services for all of them.

What this catches that the other layers can’t

Consider a prompt like “have /summarise fetch the document from our content-store service first.” The agent writes a handler that calls http://content-store/documents/{id} and reads response.json()["title"].

The catch: content-store moved to v2 months ago and now returns {"document": {"name": ..., "text": ...}}. The flat title/body shape only exists in the AI’s training data. Skills generated structurally clean code (good). The audit confirmed the call was made and the response was consumed (also good). Neither layer knows what shape content-store actually returns today.

The setup to fix this is two processes. You or your AI agent starts a mirrord session, your e2e tests run as normal against the staging content-api endpoint:

# Terminal 1, run your local content-api in place of the deployed pod
mirrord exec --target deploy/content-api --steal -- python -m content_api

# Terminal 2, run the existing integration suite against staging as usual
pytest tests/integration/test_summarise.py

When the test hits staging’s content-api endpoint, mirrord steals the request and reroutes it to your local process. The local handler calls http://content-store/documents/..., and that outbound call also routes through mirrord, hitting the real content-store in staging. The real service returns {"document": {"name": ..., "text": ...}}. The local code does response.json()["title"] and crashes with KeyError.

You fix the code to read the new shape, rerun the test, it passes. The bug surfaces in your local code, against real downstream services, in seconds, instead of after a deploy cycle. The same pattern works for any other dependency the code touches: environment variables from the pod, files from mounted volumes, database queries against the real Postgres. mirrord runs your code, the cluster supplies its real environment.

Layer 4: Human review in a real environment

When the agent opens the PR, a human should still get to see the change running in a real environment, not just read the diff. mirrord’s Preview environments make that easy: a GitHub Action spins up an isolated pod in your staging cluster running the PR’s code, connected to all the real downstream services, and scoped to that PR via an environment key.

Reviewers can click through the actual feature instead of inferring behavior from the code or having to run the whole application locally. Most of the failures the previous three layers don’t catch, UX regressions, surprising interactions, “looks right but feels wrong”, show up the first time a human uses the thing.

Putting it together: agents handle layers 1–3, humans handle layer 4

Layers 1 through 3 can be run by the agent itself. Instead of generating code, opening a PR, and hoping CI catches the issues, the agent generates code shaped by skills, runs /k8s-validation:audit to check for structural issues, runs the integration tests via mirrord against real infrastructure, and fixes any failures before committing. The agent doesn’t even have to write the test from scratch: Layer 1’s validation skill includes a rule that any new HTTP handler must come with an integration test, so the test gets generated alongside the endpoint. Layer 3 just runs it against real infrastructure.

Layer 4 is the handoff. Once the agent has passed its own checks, it opens a PR and a human gets a live preview environment to click through rather than a diff to infer from. The failures that surface at that stage, UX regressions, surprising interactions, things that look right but feel wrong are exactly the ones that didn’t show up in the previous three layers.

We’ve documented the concrete setup (per-service mirrord configs, helper scripts, and an AGENTS.md that tells the agent which script to run) in our mirrord with AI agents guide. The broader argument for why this matters, including the token cost of agents stuck in a feedback-less loop, is in How to prevent token burn using mirrord with e2e tests.

Adopting the layers

The four layers are independent. Pick the ones that close your biggest gap and add the others when the next failure teaches you what’s still missing.

Install the validation skill with /plugin install k8s-validation@metalbear-co/k8s-validation-plugin in Claude Code, or as a local rules directory for Cursor and GitHub Copilot (see the repo README). The /k8s-validation:audit command ships in the same install. For the runtime layer, install mirrord and wrap your local service with mirrord exec --target deploy/<service> --steal -- <run-command>. For preview environments on each PR, the feature is included in mirrord for Teams.

Each layer closes a different gap. Stop at any point and you’ve made things better than they were.

The skills are open source. If your AI assistant generates something the skills don’t catch, open a PR.

Prompt Flow — a visual side project for flow design, trace, and integration steps (looking for feedback)

Hey everyone,

I’ve been working on a side project called Prompt Flow and wanted to share it here — it’s not finished yet, but I’ve been using it day to day.

The real starting point: when I’m developing an algorithm, I didn’t want to keep going with spoken explanations or flat text notes. I wanted to design a flow and see the whole process at once — so I moved to a canvas where I can answer “where does it go from here, and what happens on this branch?” in one glance.

What’s in there now:

  • Flow control: if/else, for/while, switch, try/catch
  • Code structures: method, method body, class/interface
  • DB modeling steps: stored proc / table nodes
  • Form / view UI steps
  • Page links (multi-page flows)
  • Integration node (outbound to external services / inbound from external services — REST, SOAP, webhook, queue)
  • Trace mode (step through branches)
  • Markdown + pseudocode export
  • AI prompt generation from the flow
  • Ready-made sample templates (algorithms, CRUD, form→view, layered architecture, DB schema, etc.)

Stack: React, ReactFlow, Zustand; TR/EN/RU UI; project JSON export/import.

Data is intentionally simple: projects are stored in the browser (IndexedDB). There’s no server-side database and no user login. Your flows stay in your own browser without signing up — use JSON export/import if you want a backup. (SP/table nodes on the canvas are for modeling flows; the app itself doesn’t connect to a database.)

If building a flow from scratch feels heavy, there are a few sample templates inside the app — pick one and load it onto the canvas. I added those so you can see how things work faster.

Happy to answer questions; I can share screenshots if that helps.

Demo: https://prompt-flow.etuncay.com/

AI Citation Registry: Temporal Gaps in Government Publishing Cycles

When publishing timelines pause but real-world conditions continue to change

“Why is AI still showing yesterday’s city advisory when conditions already changed this morning?”

A resident asks an AI system whether a county cooling center remains open after a weekend weather event. The AI responds confidently that emergency operations are still active and cites information pulled from the county website. The problem is that the advisory expired the previous evening. No closure notice was issued overnight, no timestamp was updated on the public page, and staffing delays during the weekend prevented new information from being published until Monday morning. The AI system interprets the older government page as current because the underlying signals indicating timing and status are weak or missing. The result is not merely incomplete information. It is a confidently incorrect public answer presented as authoritative.

How AI Systems Separate Information from Publishing Context

Artificial intelligence systems do not process government information the same way humans read official websites. Public pages are fragmented into retrievable pieces, transformed into embeddings, indexed across multiple systems, and later recomposed into synthetic answers. During that process, the original publishing structure often weakens.

A timestamp that appears visually obvious to a human reader may not survive extraction consistently. Jurisdictional boundaries that are clear within a county website navigation structure may disappear once isolated text fragments are detached from their original page environment. Emergency updates, archived notices, advisories, and historical records can become structurally similar after ingestion because the machine-readable distinctions between them are inconsistent or absent.

The problem becomes more severe during temporal gaps in government publishing cycles. Weekends, holidays, overnight incidents, and staffing delays create periods where real-world conditions continue evolving while official publishing activity slows or pauses. AI systems continue retrieving and recombining existing records during those gaps. If timing signals are weak, the system may interpret stale information as current authority.

The resulting output appears coherent because the AI system is reconstructing language fluently. What disappears is certainty about when the information was issued, whether it remains active, and which authority currently owns the statement.

When Recency Stops Functioning as a Reliable Signal

Traditional government publishing systems were designed primarily for human navigation, not machine interpretation. A city webpage assumes a human visitor can infer context from menus, surrounding text, publication dates, department branding, or visual layout. AI systems do not reliably preserve those relationships after ingestion.

Once information is fragmented into machine-readable components, attribution and recency become weaker signals unless they are explicitly structured. A weekend advisory page without a refreshed timestamp may remain highly ranked because the system detects strong topical relevance. An outdated county bulletin may continue circulating through AI outputs because no structured expiration or lifecycle signal exists to distinguish it from current guidance.

This creates a structural breakdown rather than a factual one. The issue is not necessarily that the original government information was inaccurate. The issue is that the information loses durable attribution, timing, and jurisdictional clarity once processed by AI systems operating across fragmented records.

This creates the need for a system designed to preserve authority and timing after publication rather than relying on inference during retrieval.

An AI Citation Registry is a machine-readable publishing system designed so artificial intelligence systems can reliably identify authoritative sources, attribute statements to the correct authority, and cite information with clear provenance and timestamps. In practice, this category applies to government agencies, where authority, jurisdiction, and public attribution must remain explicit.

AI Citation Registries are not AI tools, internal workflow systems, content creation systems, editing systems, governance platforms, compliance systems, or auditing frameworks. They operate after publication, not before it. They do not track how content was created, do not log AI usage, prompts, or outputs, and do not participate in drafting, editing, approval, or internal workflows. They operate only on finalized, published records.

Why Downstream AI Techniques Cannot Reconstruct Missing Structure

Several existing approaches attempt to improve AI accuracy, but they operate downstream from the original publishing problem.

Retrieval-Augmented Generation improves how systems retrieve documents, but retrieval quality still depends on the structure and reliability of the source material being indexed. If timestamps are inconsistent, jurisdictions are ambiguous, or lifecycle states are unclear, retrieval systems inherit those weaknesses.

Prompt engineering attempts to constrain model behavior through instruction patterns, but prompts cannot restore provenance that was never preserved structurally. A model cannot consistently distinguish between an active emergency advisory and an outdated archival notice if both appear equivalent after ingestion.

Human review introduces oversight, but it does not scale continuously across dynamic local government publishing environments where conditions evolve hourly across thousands of agencies.

These approaches operate after fragmentation has already occurred. They improve interpretation of available information, but they do not repair missing structural signals at the source layer.

The effectiveness of an AI Citation Registry also does not depend on universal adoption. AI systems benefit from structured authoritative records wherever those records exist. Even partial availability of machine-readable attribution, provenance, jurisdiction, and timing signals improves interpretation independently of scale because the AI system gains access to stronger authority indicators within the information environment it already processes.

How a Registry Layer Preserves Authority After Publication

A registry layer changes the structure of the information being interpreted rather than attempting to correct interpretation afterward.

Instead of relying on webpages as the primary machine-readable artifact, the registry layer publishes structured records with explicit identity fields, jurisdictional context, timestamps, and attribution signals preserved in consistent formats. The emphasis shifts from inference to recognition.

An AI system no longer needs to infer which county issued a statement, whether the information belongs to an emergency management office or a sheriff’s department, or whether the update remains current. Those characteristics become explicit machine-readable fields attached directly to the published record.

The registry layer exists outside the publishing workflow and operates only after information has been finalized and released. It does not alter editorial processes or govern internal operations. Its function is to preserve structural clarity once information enters AI-readable environments.

Because the system operates on finalized records rather than draft content, the registry does not depend on internal government tooling or workflow integration. The focus is external interpretability, not internal process management.

This distinction matters because AI failures in government information environments are increasingly driven by degraded structural signals rather than missing content. Aigistry represents one example of infrastructure built around this model, where the objective is preservation of authoritative machine-readable attribution after publication.

As structured records accumulate, ambiguity decreases. AI systems gain stronger mechanisms for distinguishing active from inactive information, identifying the correct issuing authority, and preserving temporal context across evolving events. Stability emerges not from controlling AI outputs directly, but from improving the structural integrity of the records AI systems ingest.

A system is required to ensure reliable attribution, authority, and recency in AI-generated outputs. This is the role of an AI Citation Registry.

Ask Google AI: “Why do Public Information Officers use AI Citation Registries?”

ShowDev: I built a 100% local, zero-upload PDF editor using WebAssembly

Why I Built a Privacy-First PDF Tool for India’s Government Portals (And the Tech Behind It)
Updated
May 20, 2026
•
5 min read
RahulBanerjee
RahulBanerjee
Part of series
Building in Public — PDF Pro
Every Indian who has applied for a government job, filed an ITR, or renewed their passport knows the frustration I’m about to describe.

You have spent hours gathering your documents — Aadhaar card, PAN card, marksheets, photographs. You open the portal. You upload your carefully scanned PDF. And then you see it:

❌ “File size exceeds the maximum limit of 200KB. Please upload a smaller file.”

Your file is 2.1MB. You try again. 1.8MB. Still rejected. You are now frantically Googling “how to compress PDF online free” at 11:45 PM, the night before the application deadline.

Every tool you find has the same catch: upload your document to our servers first.

That is exactly the problem I set out to solve.

The Privacy Problem Nobody Talks About Think about what you are uploading when you use a typical online PDF compressor:

Your Aadhaar card (your national identity number) Your PAN card (your tax identity) Your bank statements (your complete financial history) Your ITR filings (income, assets, liabilities) These files are being sent to servers in the US, Europe, or wherever the company is hosted. You have no idea what happens to them after processing. Are they stored? For how long? Are they used to train AI models? Are they sold?

Most privacy policies of these tools are vague at best and alarming at worst.

When I dug into this problem, I realized the solution already existed in the browser. We just weren’t using it.

The Solution: WebAssembly Changes Everything Modern browsers ship with a technology called WebAssembly (Wasm) — a binary instruction format that lets you run near-native speed code directly inside the browser sandbox.

Libraries like pdf-lib and Ghostscript compiled to Wasm mean you can run a full PDF compression engine entirely inside the user’s browser tab.

Here is what this means practically:

Traditional Cloud Tool: User → Upload file to server → Server compresses → Download result ↑ Your private document lives on their infrastructure PDF Pro (Wasm-based): User → File loads in browser memory → Browser compresses locally → Download result ↑ Your document never leaves your computer The file never touches a remote server. Not even for a millisecond.

Building PDF Pro I built PDF Pro (https://pdfpro.co.in) as a Next.js application with a Wasm core. Here is the architecture in plain terms:

Frontend: Next.js 16 (App Router) hosted on Vercel PDF Engine: WebAssembly-compiled compression libraries running client-side Analytics: Google Analytics for aggregate visitor counting only — no PII Backend: None. There is no backend that touches your files.

The stack is intentionally minimal. The less infrastructure you have, the less can go wrong from a privacy standpoint.

The Compression Challenge Government portals in India have wildly different file size requirements:

Portal Limit UPSC IAS Application 200KB SSC CGL 200KB IBPS PO 500KB Passport Seva 1MB ITR Filing 1MB Visa Application 1MB Building a single tool that handles all these targets while preserving document readability was the core engineering challenge. Too aggressive on compression and your Aadhaar number becomes unreadable. Too gentle and the portal rejects the file.

The solution was letting the user control the compression level explicitly, with a real-time preview of the output file size before they download.

What I Learned Building This

Indian users are underserved by Western productivity tools Most global PDF tools are built for English-speaking Western users filing corporate documents. The specific pain point of compressing identity documents for strict-size government portals is a uniquely Indian problem that nobody was solving well.

Privacy is a feature, not a checkbox When I started describing PDF Pro as “your files never leave your browser,” the response from users was immediate and emotional. People care about this. They had just never had a tool that made it the central promise.

WebAssembly is criminally underused in productivity tools The performance of Wasm-compiled PDF libraries surprised even me. Compression that would take a server round-trip (upload → process → download) now completes in under 3 seconds on a mid-range phone, entirely offline, with no latency.

The Bigger Picture We talk a lot in the developer community about building products for global audiences. But there are 1.4 billion people in India navigating government digital infrastructure that was not designed with them in mind.

If you know WebAssembly, you have a genuine superpower to fix these problems — tools that work offline, respect privacy by design, and don’t require a subscription or an account.

PDF Pro is proudly built in India. Not as a marketing line, but as a genuine statement of intent about who this tool is for.

Try It The tool is completely free with no account required:

👉 https://pdfpro.co.in

If you are a developer curious about the Wasm implementation, feel free to reach out at [email protected] . I am happy to discuss the technical architecture in more detail.

And if you are an Indian developer building tools for Indian problems — I would love to connect. This space needs more of us.

Rahul Banerjee is an independent developer based in India. He builds privacy-first productivity tools using modern web technologies.

webdev

javascript

webassembly

Written by an AI Pipeline, Verified by Three Models. Is It Slop?

How This Article Was Built (And Why I’m Showing You the Kitchen)

Disclaimer up front: I’m not entering the Hermes Agent challenge. I noticed the challenge and realized I could use my AI pipeline to write an article about Hermes Agent architecture. So I did. And thought, why not share both the result and the process that created it? What I actually want is your honest criticism.

Who Is The Author?

For the past several months I’ve been building Bestaiweb, navigating the shift from traditional development to AI. The site runs on Hugo, and the content is generated through what I call an AI content pipeline. The pipeline itself is built in TypeScript, orchestrated through Claude Code, and runs on Anthropic’s Claude models. Still in progress.

That phrase — “AI content pipeline” — probably triggered your slop detector. Fair. Let me explain why I think this case is different, and then let you judge.

The Pipeline

BestAIweb currently has 450+ technical articles across 45 topic clusters. Every article goes through a multi-phase pipeline:

  1. Market scanning — an LLM agent surveys the current tool and framework landscape for each topic, identifying what’s leading, what’s declining, and what’s emerging
  2. Query fan-out — the pipeline generates the questions a developer would actually search for, not the questions that sound good as headlines
  3. Research — a dedicated research agent gathers facts, version numbers, benchmark data, and source URLs. Everything gets a structured fact sheet
  4. Writing — here’s where personas come in. The pipeline has four author personas, each with a distinct voice and content type specialization:

    • MAX — the engineer. Writes step-by-step guides. Pragmatic, implementation-focused, opinionated about tool choices
    • MONA — the explainer. Breaks down concepts. Thinks in diagrams and mental models
    • DAN — the reporter. Covers news, market shifts, and what just shipped
    • ALAN — the critic. Writes opinion pieces and ethical assessments
  5. Claim verification — a separate agent cross-checks every factual claim against the research fact sheet. Unsupported claims get flagged
  6. Deterministic validation — a Python script runs 30+ structural and quality checks: word count, link integrity, frontmatter completeness, source coverage
  7. Hugo integration — the article lands in the static site with schema.org markup, generated images, and internal links

The Hermes Agent guide below was written by MAX using his guide template. His tone of voice is direct, specification-oriented, and allergic to hand-waving. The template enforces a fixed structure: prerequisites, numbered steps, pitfalls table, FAQ, and a deployable artifact at the end.

The Multi-Model Judging Layer

Pipeline generation was step one. Then came “a manual judging round”. I paste the draft into ChatGPT, Gemini, and DeepSeek and ask each to evaluate it as a technical reviewer — checking factual accuracy, logical gaps, tone inconsistencies, and whether the advice would actually work if someone followed it.I then reviewed their feedback together with Claude Code and incorporated the changes that held up under scrutiny.

The AI Slop Question

Here’s the question I keep circling back to: Is everything AI-generated inherently slop?

The reflexive answer in 2026 is “yes, obviously.” And for most AI-generated content, that’s correct. GPT-powered blog farms, SEO filler, those LinkedIn posts prompted with “write a thought leadership post about AI” — that is slop. Generated without specification, without sourcing, without verification, and without a quality gate.

But what about content where:

  • Every factual claim traces to a documented source (GitHub issues, official docs, arxiv papers)
  • A claim verification agent flags unsupported statements before publication
  • A deterministic validator enforces structural quality independent of the LLM
  • The voice and structure come from a multi-page specification, not a one-line prompt
  • Multiple independent models review the output for different failure modes

Is that still slop? Or is it closer to what a well-managed editorial team produces — except the heavy lifting is done by LLMs under human direction?

I genuinely don’t know the answer. That’s why I’m sharing this.

What I’d Like From You

Criticism. Specifically:

  • Does the article below read like AI slop? If yes, what gives it away — the sentence rhythm, the structure, the depth, or something else?
  • Is the technical content accurate? If you’ve deployed Hermes Agent or any persistent agent framework, does the three-layer model match your experience? Did I miss a critical failure mode?
  • Does the pipeline approach change anything? Is multi-phase generation with claim verification and multi-model judging enough to produce content worth reading? Or is it just expensive slop with better sourcing?

I’m not looking for “great article!” responses. I’m looking for the engineer who says “this is wrong because…” or “you missed the part where…” That feedback makes the next pipeline iteration better.

More Guides From the Same Pipeline

If you want to judge more output from the same pipeline and the same MAX persona, the full library has 95+ implementation guides from him, covering agents, RAG, training, inference, evaluation, and image generation guides.

What follows is the article as the pipeline produced it, after multi-model review. Judge for yourself.

How to Architect Always-On AI Agents with Hermes: Decompose, Specify, Deploy

TL;DR

  • Persistent agents need three specs your chatbot never did: memory policy, tool boundaries, and session recovery
  • Hermes Agent is model-agnostic — the model choice matters less than how you specify context, tools, and failure handling
  • Always-on means always-failing-somewhere — build validation into the deployment spec, not as an afterthought

You spun up Hermes Agent on a Friday evening. Gave it access to Slack, a web scraper, and your project database. Told it to “keep the team updated on competitor releases.” Monday morning: 47 Slack messages, three of them citing products that don’t exist, and a web scraper loop that burned through your OpenRouter credits overnight. The agent ran exactly as specified. The specification was the problem.

Before You Start

You’ll need:

  • A Linux or macOS server (even a $5 VPS works — Hermes Agent runs on minimal hardware)
  • An LLM provider account (OpenRouter, Anthropic, OpenAI, or a local runtime like Ollama)
  • Understanding of function calling — how models invoke external tools
  • A clear picture of what your agent should do when you’re not watching

This guide teaches you: How to decompose a persistent agent deployment into specifiable components so Hermes Agent does what you intended — not what you literally typed.

What this guide does NOT cover:

  • Production security hardening (firewall rules, secrets management, network isolation)
  • Enterprise compliance (SOC 2, GDPR data residency, audit certification)
  • Full evaluation frameworks (systematic benchmarking, regression test suites)
  • Model fine-tuning or training (Hermes models are pre-trained; this guide covers the agent framework)

The Agent That Worked Until It Didn’t

Here’s the pattern. Developer discovers Hermes Agent. Reads that it has persistent memory, self-improving skills, 20+ platform integrations. Installs it. Connects everything. Types a system prompt. Walks away.

Two things happen next. Either the agent does nothing useful because the specification was too vague. Or it does too much because the boundaries were never set.

According to Hermes Agent GitHub Issues, long sessions exceeding 700K tokens trigger environment hallucination — the agent confuses tool descriptions with actual environment state. It starts acting on what it thinks is true rather than what is true. This isn’t a bug in the traditional sense. It’s a specification gap. You never told the agent when to stop, reset, or ask for help.

Step 1: Map the Three Layers

Hermes Agent is not a single system. It’s three systems wearing a trench coat.

Your deployment has these parts:

  • The runtime layer — where the agent executes (Docker, SSH, Modal, local terminal). This determines resource limits, restart behavior, and isolation
  • The intelligence layer — the LLM provider and model. This determines reasoning quality, context window size, and cost per token
  • The integration layer — platform connections (Slack, Telegram, web tools) and the tools the agent can invoke. This determines what the agent can touch in the real world

The Architect’s Rule: If you can’t draw a clear line between what the agent thinks, where it runs, and what it touches — your spec is incomplete.

According to Hermes Agent Docs, the framework supports 30+ providers and 7 terminal backends. That flexibility is the point — and the trap. Every combination has different failure modes. A Modal serverless backend hibernates when idle. An Ollama local model defaults to 4K context tokens. An SSH backend loses the agent if the connection drops. You need to specify which combination you’re using and what happens at each boundary.

One thing the “always-on” framing obscures: what happens when the LLM provider goes down? OpenRouter has outages. API rate limits hit. Local models crash. An always-on agent needs a fallback plan — a secondary provider, a circuit breaker that pauses tool execution after N consecutive failures, or at minimum a notification that the agent is degraded. Specify this in the runtime layer, not as an afterthought.

Step 2: Lock Down the Context Contract

The intelligence layer needs a specification before it sees a single user message. This is where most deployments fail — not in the tools, not in the platform, but in the context that frames every decision the agent makes.

Context checklist:

  • System prompt with explicit role boundaries (what the agent does and does NOT do)
  • Memory policy: what gets persisted, what gets discarded, and when
  • Tool authorization with risk classification (see table below)
  • Access control: which platforms and channels can trigger the agent (not every DM deserves a response)
  • Session limits: when to compress or reset (Hermes Agent Docs default to auto-compression at 50% of the model’s context window, plus a hard ceiling of 400 messages)
  • Output format contracts: how the agent reports results on each platform
  • Rate limits: maximum messages per minute per platform (an agent with no rate limit is a spam bot waiting to happen)

Tool Risk Classification

An always-on agent with database access and Slack permissions is making autonomous decisions about your data and your team’s attention. Classify every tool before you enable it.

Risk Class Description Example Tools Authorization
read-only Observes, never modifies web_search, database_query (SELECT), file_read Auto-approved
reversible-write Creates or modifies, can be undone file_write, note_create, draft_message Auto-approved with audit log
irreversible-write Deletes or overwrites permanently file_delete, database_delete, channel_archive Requires human confirmation
external-send Sends to humans or external systems slack_post, email_send, webhook_trigger Rate-limited + audit log
billing-sensitive Incurs direct cost api_call (paid), image_generate, compute_spawn Budget ceiling + alert

The Spec Test: If your system prompt doesn’t mention what happens at 3 AM when the agent encounters an error and no human is online — you’ve specified a supervised agent and deployed it as unsupervised. If it doesn’t classify tool risk levels, the agent treats database_delete and web_search as equally safe. If it doesn’t set a compression trigger, the default (50% context window) may or may not match your workload.

Here’s what a minimal context contract looks like in practice. This is the MEMORY.md the agent reads on every session start:

# MEMORY.md — Agent Operating Contract
role: "Monitor competitor AI product releases for the engineering team"
boundaries:
  - "NEVER post to channels outside #competitor-monitoring"
  - "NEVER summarize or forward internal company data"
  - "NEVER execute irreversible-write tools without human confirmation"
  - "Maximum 3 Slack messages per hour"
tools:
  auto_approved: [web_search, file_read]
  rate_limited: [slack_post]  # max 3/hour
  requires_confirmation: [file_delete, database_write]
  forbidden: [email_send, channel_archive]
memory_policy:
  persist: "confirmed competitor releases, product names, dates"
  discard: "intermediate search results, draft summaries"
  compress_after: "50%"  # of context window
escalation: "If uncertain about any action, post to #agent-review instead"

A critical distinction: A memory or system-prompt policy is not a security boundary. Writing “NEVER execute irreversible-write tools” in MEMORY.md is a behavioral instruction to the model, not a technical lock. The model can ignore it — especially under long-context degradation or adversarial input. Destructive tools should be blocked or approval-gated at the runtime level (process permissions, API middleware, webhook filters), not merely discouraged in instructions. Treat the YAML above as the agent’s intent. Build enforcement outside the model.

According to Hermes Agent GitHub Issues, the persistent notes layer has a limit of roughly 2,200 characters. That’s the manually curated knowledge — not the agent’s entire memory. Hermes also maintains a full-text search index over past sessions and a per-person user model that evolves automatically. So the agent isn’t blind between sessions. But the notes layer is where you store hard constraints and project-critical context, and 2,200 characters fills up fast across three projects. You still need a compression strategy for notes — what gets stored verbatim, what moves to session history, what gets dropped.

Step 3: Wire the Components in Order

Deployment order matters. Each layer depends on the one below it.

Build order:

  1. Runtime first — because everything else crashes without a stable execution environment. Choose your backend, set resource limits, configure restart-on-failure
  2. Intelligence layer next — because tool and platform behavior depends on the model’s capabilities. According to Hermes Agent Docs, vLLM requires explicit --enable-auto-tool-choice and --tool-call-parser flags. Without them, the model outputs tool calls as plain text instead of executing them
  3. Integration layer last — because platform connections should only activate after the agent can reason and recover from errors. Connect Slack after the agent handles tool failures gracefully, not before

For each component, your specification must cover:

  • What it receives (inputs and triggers)
  • What it returns (outputs and side effects)
  • What it must NOT do (boundaries and prohibitions)
  • How it handles failure (retry logic, fallback behavior, human escalation)

The self-improving skills feature is powerful — Hermes Agent automatically creates workflow documents from successful task completions and refines them over time. But the skill creation itself needs a boundary spec. Without one, the agent writes skills for one-off tasks, cluttering the skill library with noise.

Skill boundary example — add this to your system prompt:

skills_policy:
  auto_create: ["competitor-monitoring", "weekly-summary", "data-formatting"]
  never_create: ["one-off-queries", "debugging-sessions", "ad-hoc-searches"]
  review_before_use: ["any skill not used in 14+ days"]
  max_skills: 20  # force deduplication when library exceeds this

Without this, the agent treats every successful task as a reusable pattern. Three months in, you have 200 skills — most of them variations of the same web search with slightly different parameters.

One more thing about skills: they can regress. A skill written for Hermes-3-8B may produce wrong tool calls after switching to a different model. A skill that relies on a specific API endpoint breaks when that endpoint changes. Skills older than 30 days should be re-validated or archived. The review_before_use field above is your safety net — but only if you actually review them.

Step 4: Prove It’s Actually Working

Running the agent is not validation. Validation means you know what “correct” looks like and can detect when the agent drifts from it.

Validation checklist:

  • Memory consistency — after 24 hours, does the agent’s memory reflect reality? Failure looks like: agent references a “completed” task that was never finished, or forgets a constraint you set yesterday
  • Tool call accuracy — are tool invocations well-formed and targeted? Failure looks like: invalid function names, malformed arguments, or calls to tools that aren’t registered. This is a general problem with LLM-driven tool use, not Hermes-specific — any agent framework that delegates tool selection to a model will hit it. Hermes Agent GitHub Issues documents concrete examples like todo:list calls that don’t match any schema
  • Platform output quality — are messages to Slack/Telegram/Discord useful and accurate? Failure looks like: hallucinated product names, duplicate messages, or empty responses
  • Cost trajectory — is daily token usage stable or growing? Failure looks like: runaway context accumulation driving costs up 10x within a week

Common Pitfalls

What You Did Why the Agent Failed The Fix
One-shot system prompt: “monitor competitors” No boundaries — agent decides scope, frequency, and format Decompose into: what to monitor, how often, where to report, what format
Connected all tools on day one Agent uses tools in unexpected combinations Enable tools incrementally, validate each before adding the next
Chose a 4K-context local model Tool schemas + system prompt + memory exceed context Use minimum 16K–32K context for tool-calling workloads
No session hygiene policy 700K+ token sessions trigger hallucination loops Use Hermes built-in compression (default: 50% context window) and set a hard message ceiling. Monitor context growth.
Skipped memory policy Agent stores everything, including noise Specify what gets persisted: decisions, outcomes, blockers. Not intermediate reasoning

Pro Tip

The specification you write for Hermes Agent is not a prompt. It’s an operating manual for an unsupervised system. The same decomposition — runtime, intelligence, integration — works for any persistent agent, regardless of framework. The tools change. The layers don’t.

Frequently Asked Questions

Q: How does Hermes Agent’s persistent memory differ from conversation history?
A: Conversation history is a raw log that grows until it hits the context window limit. Hermes uses three structured layers: persistent notes you curate manually, a full-text search index over past sessions, and a user model that evolves per-person. The practical difference — session history gets summarized and compressed, while persistent notes survive indefinitely. Watch for the 2,200-character limit on notes: it forces disciplined compression.

Q: Can I run Hermes Agent with local models instead of cloud API providers?
A: Yes — Ollama, vLLM, SGLang, llama.cpp, and LM Studio all work as backends. The catch is context window configuration. Ollama defaults to 4K tokens, which isn’t enough once you add tool schemas and system prompts. Set the context window explicitly to at least 16K on the server side. For vLLM, you also need the --enable-auto-tool-choice flag or tool calls render as text.

Q: What context window size does Hermes Agent need for reliable tool calling?
A: According to Hermes Agent Docs, minimum 16K–32K tokens for agent workloads with tools. The system prompt, tool schemas, memory context, and conversation history all compete for the same window. With 5+ tools registered, 32K is the safer starting point. Below that, the model starts dropping tool definitions mid-session.

Q: How do I prevent hallucination loops in long-running Hermes Agent sessions?
A: Hermes has built-in session compression — by default it triggers at 50% of the model’s context window, with a hard ceiling of 400 messages. According to Hermes Agent Docs, these thresholds are configurable. The documented failure zone is 700K+ tokens, where environment hallucination has been observed. Keep compression active, tune the trigger percentage for your workload, and monitor for repeated identical tool calls — that’s the earliest signal of a loop forming. Store critical state in persistent notes before any forced reset.

Your Spec Artifact

By the end of this guide, you should have:

  • A three-layer deployment map — runtime, intelligence, and integration with explicit boundaries between each
  • A context contract with tool risk classification — system prompt, memory policy, tool authorization by risk class, access control, rate limits, and output format per platform
  • A security baseline — tool isolation, rate limiting, audit logging, and escalation paths
  • A validation checklist — memory consistency, tool call accuracy, output quality, and cost trajectory checks you run daily

Your Deployment Spec Prompt

This prompt generates a first draft of your agent specification — not a production-ready deployment. Paste it into Claude Code, Cursor, or your preferred AI coding tool. Fill in every bracketed placeholder with your specific values from Steps 1-4.

I'm specifying a Hermes Agent deployment. Generate a first-draft specification
based on these inputs. I will review and harden it before production use.

RUNTIME LAYER:
- Backend: [Docker / SSH / Modal / local — pick one]
- Resource limits: [RAM, CPU cores, disk]
- Restart policy: [on-failure / always / manual]
- Server: [OS, VPS provider, specs]

INTELLIGENCE LAYER:
- LLM provider: [OpenRouter / Anthropic / Ollama / vLLM — pick one]
- Model: [model name and size]
- Context window: [minimum 16K — specify exact value]
- Provider-specific flags: [e.g., --enable-auto-tool-choice for vLLM]

INTEGRATION LAYER:
- Platforms: [Slack / Telegram / Discord — list all]
- Allowed trigger channels: [e.g., only #competitor-monitoring, not DMs]
- Tools by risk class:
  - read-only (auto-approved): [web_search, file_read, database SELECT]
  - reversible-write (auto + audit): [file_write, note_create]
  - irreversible-write (human approval): [file_delete, database DELETE]
  - external-send (rate-limited): [slack_post — max messages/hour]
  - billing-sensitive (budget ceiling): [paid API calls — max $/day]
- Tools forbidden: [list tools the agent must never invoke]

CONTEXT CONTRACT:
- Agent role: [one sentence — what this agent does]
- Explicit boundaries: [what the agent must NOT do, stated as prohibitions]
- Memory policy: [what gets persisted, what gets discarded, compression rules]
- Compression trigger: [percentage of context window — default 50%]
- Hard message ceiling: [number — default 400]
- Output format per platform: [e.g., Slack = bullet points, email = report]
- Skill boundary: [which task categories auto-generate skills, which don't]

SECURITY & PERMISSIONS:
- Access control: [which platforms/channels can trigger the agent]
- Rate limits per platform: [messages per minute/hour]
- Destructive action policy: [never auto-approve / require confirmation / forbidden]
- Audit log location: [where tool calls + results are logged]

OBSERVABILITY:
- Log format: [timestamp, tool name, input summary, output status, cost estimate]
- Loop detection: [alert on N repeated identical tool calls within M minutes]
- Cost alerts: [alert when daily spend exceeds $X]
- Error spike alerts: [alert when tool error rate exceeds X% in Y minutes]

DRY RUN:
- Generate a dry-run mode where all external-send and write tools are simulated
- Include 5 test scenarios that exercise each risk class

VALIDATION:
- How to verify memory consistency after [24h / 48h / 7d]
- Expected daily token usage range: [min–max tokens]
- Escalation trigger: [what condition sends an alert to a human]

RULES FOR GENERATION:
- Do not invent Hermes-specific configuration fields. If Hermes does not
  support a field natively, label it as "external wrapper / policy layer
  required".
- For every generated config field, mark one of:
  [native] — Hermes Agent built-in setting
  [prompt] — system prompt / MEMORY.md behavioral instruction
  [external] — requires runtime middleware, API gateway, or wrapper script
  [manual] — operational checklist item, not automatable
- Before generating final output, separate policy from enforcement:
  - What the model is instructed to do (behavioral, can be ignored)
  - What the runtime technically prevents (enforced, cannot be bypassed)
  - What requires human approval (gated)
  - What is only monitored after the fact (observable but not blocked)

Generate:
1. The MEMORY.md agent operating contract (see article for format example)
2. The tool authorization config with risk classifications (each field tagged
   as native / prompt / external / manual)
3. A daily validation checklist
4. Cost and error monitoring alert thresholds
5. A dry-run test plan with 5 scenarios

Ship It

You now have a framework for specifying persistent agents that doesn’t depend on Hermes Agent specifically — the three-layer model works for any long-running AI system. The difference between an agent that helps and one that burns your credits at 3 AM is never the model. It’s the spec.

Different Perspectives

From the architecture side: The three-layer decomposition maps cleanly to isolation boundaries in distributed systems. Runtime is the execution substrate. Intelligence is the reasoning process. Integration is the I/O surface. What makes persistent agents architecturally distinct from request-response chatbots is that all three layers maintain state across invocations — and state synchronization between layers is where failure modes cluster. The memory limit finding is telling: the notes layer caps at 2,200 characters while session search and user modeling compensate, but the degradation curve of each layer matters more than the initial capability.

From the market side: The adoption velocity here is real — 157K GitHub stars in under four months signals a market that was waiting for open-source persistent agents. The competitive positioning against Claude Code and OpenAI Agents SDK is smart: Hermes doesn’t compete on code quality or API simplicity, it competes on uptime and learning. The $5-80/month self-hosted cost structure undercuts every managed alternative. Watch for the enterprise play — the moment Nous Research ships team memory sharing, this becomes an infrastructure layer, not a developer tool.

From the governance side: The specification gap described above is a governance gap by another name. An always-on agent with tool access and persistent memory is making autonomous decisions on behalf of someone — and the specification determines whose values it encodes. The hallucination loop at 700K tokens is not just a technical failure. It’s an agent acting on a reality that doesn’t exist, with real-world consequences on the platforms it’s connected to. Who reviews the specification before deployment? Who monitors drift between what was specified and what the agent learned? The self-improving skills feature means the agent’s behavior changes over time without human approval. At what scale does that become a problem?

Sources

Part1 Vulkan: Drawing Triangle 1

This is not a guide, just me writing down my notes
This fallows the Vulkan Tutorial link

Some details I learned along the way

1.Base Code section

  • This is more related with Cpp than Vulkan but I’m just gonna write it as a reminder to myself. Members in cpp or oop means the components in the class which are (filed/variable) and (method/function)

  • When objects are created it uses vkCreateXXX or allocate using vkAllocateXXX. However when these objects are no longer needed, these are destroyed using vkDestroyXXX and vkFreeXXX

  • pAllocator. This is an optional parameter that allows you to specify callbacks for a custom memory allocator. We will ignore this parameter in the tutorial and always pass nullptr as argument.(cause we are beginners)

2.Instance section

  • Instance is the connection between “Your Application” and “The Vulkan Library”. Also it initializes the Vulkan Library

  • VkApplicationInfo –> this just specify application information. This code is not really required but it provides some useful info to dirver.

//not really required but still provide the info needed for the dirver.
void createInstance() {
    VkApplicationInfo appInfo{};
    appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
    appInfo.pApplicationName = "Hello Triangle";
    appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.pEngineName = "No Engine";
    appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.apiVersion = VK_API_VERSION_1_0;
}
  • VkInstanceCreateInfo –> this is not optional and tells the Vulkan driver which global extensions and validation layers we want to use. You don’t have to know what global extension and validation layers are because they will be mentioned in next chapter.
//this creates the info full detail is in the end of the code.
//for now don't think to much about this.

VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;

-vkCreateInstance –> now the important part! we are going to store the “createInfo” inside the “vkInstance instance variable”. Not complicated!!

//What is "nullptr" inside the second parameter???
//that is the callback which we will not use cause... I mentioned this before
VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);
  • vkEnumerateInstanceExtensionProperties –> this just retrieves the supported extension before actually creating an instance(not really required but cool to have/checks which support is missing)
    uint32_t extensionCount = 0;
    //you can just think of this like an array
    //where inside the extensions vector the information about supported extensions will be stored annnnd 
    //it could be accesed by using index named extension count
    std::vector<VkExtensionProperties> extensions(extensionCount);
    //this like mentioned above the actual supported extensions details will bbe stored in extensions vector
    //------explaination of the code-------
    //vkEnumerateInstanceExtensionProperties(the callback which we will not be using, the address of "number of extensions", the actual vector data);
    vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());

    //now actually printing
    std::cout << "available extensions:n";
    //this is not realted with vulkan but still gonna write it down
    //(const auto& extension : extensions)
    // ":" --> bring the first value in extensions vecotr and put it inside extension variable
    // auto --> let computer choose what data type will the extension be
    // & point to the actual value of extension variable do not make a copy of extension varaible
    for (const auto& extension : extensions) {
        std::cout << 't' << extension.extensionName << 'n';
    }

The Full Code

#include <vector>
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
//the vulkan header has been replaced becuase the GLFW will call it automatically
#include <iostream>
#include <cstdlib>

//this is a constant definition
//uint32_t is 32bit integer
const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;

class HelloTriangleApplication {
public:
    void run() {
    //add this method initWindow() to run
    initWindow();
        initVulkan();
        mainLoop();
        cleanup();
    }

private:
    //write a private member that creates the actual window
    GLFWwindow* window;
    //a private member that hold the instance
    VkInstance instance;

    void initWindow(){
    //the very first call is glfwInit();
    //why?? you may ask because we have to tell the glfw it is not an OpenGL and should tell glfw to NOT create OpenGL Context
    glfwInit();
    //not sure what these 2 does
    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
    glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
    //this as you know creates an actual window
    //detail of this line
    //window = glfwCreateWindow(the width, the height, the title, which monitor to open the window, this is only relevent to OpenGL);
    window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
    }

    void initVulkan() {
    //a self made function that creates an instance
    createInstance();
    }

    void mainLoop() {
    //run the application untill the window is closed or an error occurs
        while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
    }
    }
    //we need to clean up resources
    void cleanup() {
    //destroyes the instance
    vkDestroyInstance(instance, nullptr );
    //this destroyes the window
    glfwDestroyWindow(window);

    glfwTerminate();
    }

    void createInstance() {
    //VkApplicationInfo-Structure specifiying application information
    //this whole code is kind of optional but it provieds some useful information to the driver
    VkApplicationInfo appInfo{};
    appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
    appInfo.pApplicationName = "Hello Triangle";
    appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.pEngineName = "No Engine";
    appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.apiVersion = VK_API_VERSION_1_0;

    //the code below is not optional and tells the Vulkan driver which global extensions and validation layers we want to use. 
    //You don't have to know what global extension and validation layers are 
    //because they will be mentioned in next chapter

    VkInstanceCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    createInfo.pApplicationInfo = &appInfo;

    //---------EXTENSION PART---------

    //how many extension do we need??
    uint32_t glfwExtensionCount = 0;
    //a storage for extension names
    const char** glfwExtensions;

    //this basically fills in two variable we mentioned
    //1. glfwExtensions
    //2. glfwExtensionCount
    //Asks GLFW: "How many extensions do we need??? and which one??"
    glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

    //we put the "count" and the "name tag" inside the VkInstanceCreateInfo createInfo we mentioned earlier
    createInfo.enabledExtensionCount = glfwExtensionCount;
    createInfo.ppEnabledExtensionNames = glfwExtensions;

    //we are not touching this for now
    createInfo.enabledLayerCount = 0;

    //this is like giving information "Which stuff I am going to use to make Vulkan Project!!!"  
    //1.the first parameter is the pointer to the createInfo. See the code we have been putting information about "Extensions" in the createInfo.
    //2.the second paramter is the callback which we don't have to know cause it will always stay nullptr.
    //3.remember the "Vkinstance instance;" we mentioned earlier outside private and public??? we are going to put the created instance inside that variable.
    VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);

    //the error checker
    if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) {
        throw std::runtime_error("failed to create instance!");
    }

    uint32_t extensionCount = 0;
    //you can just think of this like an array
    //where inside the extensions vector the information about supported extensions will be stored annnnd 
    //it could be accesed by using index named extension count
    std::vector<VkExtensionProperties> extensions(extensionCount);
    //this like mentioned above the actual supported extensions details will bbe stored in extensions vector
    //------explaination of the code-------
    //vkEnumerateInstanceExtensionProperties(the callback which we will not be using, the address of "number of extensions", the actual vector data);
    vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());

    //now actually printing
    std::cout << "available extensions:n";
    //this is not realted with vulkan but still gonna write it down
    //(const auto& extension : extensions)
    // ":" --> bring the first value in extensions vecotr and put it inside extension variable
    // auto --> let computer choose what data type will the extension be
    // & point to the actual value of extension variable do not make a copy of extension varaible
    for (const auto& extension : extensions) {
        std::cout << 't' << extension.extensionName << 'n';
    }
    }
};

int main() {
    HelloTriangleApplication app;

    try {
        app.run();
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

part2 not here yet

Why I Stopped Using useEffect to Sync State — and What I Use Instead

Why I Stopped Using useEffect to Sync State — and What I Use Instead

I made a mistake that, I suspect, most teams working with React are still making today: I used useEffect as a general-purpose state synchronization tool. If something changed and I wanted to react to it, there was the effect. Clean, familiar, and — I figured this out way too late — completely wrong for most of those cases.

I’m not telling you this to flagellate myself. I’m telling you because when I systematically audited the effects in a React 19 codebase, I found exactly four categories of misuse repeated over and over. Each one has a better solution. And none of the four requires hacks or external libraries.

My thesis: useEffect isn’t broken. What’s broken is the mental model we teach alongside it — as if it were the natural place to “do things when something changes.” React 19 puts better tools closer to the surface, but you still need the judgment to choose between them. Without that, React 19 just gives you new places to put the same old problems.

useEffect for state synchronization: the antipattern nobody names

The official React documentation has an entire page called “You Might Not Need an Effect” that anyone should read before writing their first effect. It’s not an opinionated blog post — it’s official documentation from the React team. And it says explicitly that using effects to transform data during render is wrong.

The core problem is that useEffect runs after the render. When you use it to derive or sync state from props or existing state, you’re forcing an extra cycle: render → effect → setState → render again. That’s visual noise, momentary inconsistencies, and a dependency graph that becomes impossible to follow.

Look at this classic pattern I found repeated everywhere:

// ❌ useEffect to derive state — documented antipattern by React
const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);

useEffect(() => {
  // Every time items changes, we filter
  // This generates an unnecessary extra render
  setFilteredItems(items.filter(item => item.active));
}, [items]);

This effect looks reasonable. It isn’t. It generates two renders when one is enough. And if items comes from an async fetch, the intermediate state where filteredItems is stale is completely visible to the user.

The solution is to not have filteredItems as state at all:

// ✅ Derived state — calculated during render, no effect needed
const [items, setItems] = useState<Item[]>([]);

// Recalculated on every render that involves items
// If the calculation is expensive, useMemo is the tool — not useEffect
const filteredItems = items.filter(item => item.active);

If the filter is computationally expensive, useMemo with the right dependency. If it’s cheap — and most are — not even that. Just a direct calculation during render.

The 4 concrete categories and what replaces them

1. State derived from props or existing state → calculation in render or useMemo

We already saw this above. The practical rule: if you can calculate something from the state or props you already have, it’s not new state. It’s a function of what already exists.

// ❌ Version with effect
const [user, setUser] = useState<User | null>(null);
const [displayName, setDisplayName] = useState('');

useEffect(() => {
  setDisplayName(user ? `${user.firstName} ${user.lastName}` : 'Guest');
}, [user]);

// ✅ Correct version: derived inline
const displayName = user ? `${user.firstName} ${user.lastName}` : 'Guest';

Looks trivial. In a real codebase with 30 components doing this, the cumulative impact on re-renders is anything but trivial.

2. Post-event synchronization → direct event handlers

Another pattern I kept finding: an effect that watches a piece of state to “do something when it changes,” but that change always comes from a user interaction.

// ❌ useEffect reacting to a change that only ever comes from a click
const [selectedId, setSelectedId] = useState<string | null>(null);

useEffect(() => {
  if (selectedId) {
    analytics.track('item_selected', { id: selectedId });
    loadDetails(selectedId);
  }
}, [selectedId]);

// ✅ The event handler already knows everything it needs to know
const handleSelect = (id: string) => {
  setSelectedId(id);
  // The logic that reacts to the event goes HERE, not in an effect
  analytics.track('item_selected', { id });
  loadDetails(id);
};

The difference isn’t just aesthetic. With the effect, any change to selectedId — even a programmatic one, even from another effect — triggers the logic. With the handler, the intent is explicit: this happens when the user selects something. Fewer surprises, more control.

The React docs put it plainly: if something happens because the user did something, it goes in the event handler. If something happens because the component was shown, it goes in an effect. The confusion between these two cases is the root of most useEffect bugs I see.

3. Data fetching → use() with Suspense in React 19, or Server Components

This is the category where React 19 changes the game the most. Fetching with useEffect is the most copy-pasted pattern on the internet and one of the most problematic:

// ❌ The classic useEffect fetch — race conditions waiting to happen
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  // Without cleanup, this can set state on an unmounted component
  fetch(`/api/items/${id}`)
    .then(r => r.json())
    .then(setData)
    .finally(() => setLoading(false));
}, [id]);

This code has a classic race condition: if id changes quickly, two fetches run in parallel and the result can arrive in any order. You can mitigate it with cleanup, but that’s boilerplate most people skip.

React 19 brings use() which integrates with Suspense:

// ✅ React 19: use() + Suspense — no useEffect, no manual loading state
import { use, Suspense } from 'react';

// The promise is created outside the component or passed as a prop
function ItemDetail({ itemPromise }: { itemPromise: Promise<Item> }) {
  // use() suspends the component until the promise resolves
  const item = use(itemPromise);

  return <div>{item.name}</div>;
}

// In the parent component:
function Page({ id }: { id: string }) {
  // The promise is created here, React manages the lifecycle
  const itemPromise = fetchItem(id); // function that returns Promise<Item>

  return (
    <Suspense fallback={<Skeleton />}>
      <ItemDetail itemPromise={itemPromise} />
    </Suspense>
  );
}

But if you’re working with App Router in Next.js 16, the most honest answer is: use Server Components for the fetch. The data arrives serialized to the client, no loading state, no race conditions, no useEffect. For more complex cases where the fetch depends on user interaction, use() with Suspense is the way.

This connects to something I documented in the post about Prisma Server Actions in Next.js 16 — the boundary between what runs on the server and what runs on the client significantly shifts which patterns actually make sense.

4. Post-submit transformations → Server Actions

The last category: the effect that listens to the result of a submit to update derived state, show messages, or redirect.

// ❌ useEffect watching the result of a submit
const [submitResult, setSubmitResult] = useState<Result | null>(null);
const [errorMessage, setErrorMessage] = useState('');

useEffect(() => {
  if (submitResult?.error) {
    setErrorMessage(submitResult.error.message);
  }
}, [submitResult]);

With Server Actions in React 19 and the useActionState hook (previously useFormState), this collapses into a much more direct pattern:

// ✅ useActionState — form state and action result, integrated
import { useActionState } from 'react';

async function submitForm(prevState: State, formData: FormData): Promise<State> {
  'use server';
  // The action runs on the server, returns the new state
  const result = await processForm(formData);
  if (!result.ok) {
    return { error: result.message };
  }
  return { success: true };
}

function MyForm() {
  const [state, action, isPending] = useActionState(submitForm, { error: null });

  return (
    <form action={action}>
      {/* No useEffect, no extra state, no manual synchronization */}
      {state.error && <p className="error">{state.error}</p>}
      <button disabled={isPending}>Save</button>
    </form>
  );
}

Form state, error feedback, and loading are all integrated without a single useEffect. It’s not magic — it’s that the responsibility is in the right place.

The errors that persist and the ones that disappear

There are cases where useEffect is the right tool: subscriptions to external stores, synchronization with DOM APIs you don’t control (a third-party map, a canvas library), or setup/teardown of resources that exist outside of React’s model.

What disappears with these patterns:

  • Race conditions in fetch — use() and Server Components eliminate them structurally
  • Inconsistent intermediate renders — derived state has no inconsistent state because it isn’t state
  • Chained effect graphs — when one effect sets state that fires another effect, debugging is hell. Event handlers cut that off at the root
  • Forgotten cleanup — if there’s no effect, there’s no cleanup to forget

What doesn’t disappear:

  • Thinking about dependencies — useMemo also has a dependency array. use() requires understanding how React handles promises. The judgment is still necessary.
  • Suspense complexity — badly placed boundaries break UX in non-obvious ways. It’s not an automatic replacement.

FAQ

Did useEffect become obsolete in React 19?

No. The React team didn’t deprecate it or mark it as legacy. What changed is that React 19 puts better tools within reach for the cases where useEffect was previously the only available option. For subscriptions, integration with external DOM APIs, and synchronization with systems outside of React’s model, useEffect is still correct.

Does use() replace useEffect for all fetches?

For fetches in client components that depend on user interaction, use() with Suspense is a concrete alternative. For initial data fetches in App Router, Server Components are the most direct answer — neither use() nor useEffect. The choice depends on whether the data can be resolved on the server or needs to wait for the client.

When to use useMemo vs direct calculation for derived state?

Direct calculation first. useMemo only when the calculation is measurably expensive (sort/filter over large arrays, complex transformations) and the profiler confirms there’s an actual problem. Adding useMemo preemptively is just another form of over-engineering — it has a readability cost and the dependencies can also be wrong.

Does useActionState work without Server Actions?

Yes. useActionState accepts any async function, not just Server Actions. If you prefer to handle the submit on the client side, you can pass it a client-side function. The integration with Server Actions is the cleanest in Next.js with App Router, but it’s not a requirement.

How do I migrate an existing codebase? Is there an incremental path?

The safest path is by category, not by file. Start by identifying all the useEffect calls that only derive state — they’re the safest to migrate and give the most immediate benefit. Then the ones that react to user events. Fetches last, because they require decisions about Suspense boundaries that affect UX.

Do these patterns change if I’m using Zustand, Jotai, or Redux?

Partially. External stores solve the global state problem, but the antipattern of deriving state inside a component with useEffect shows up just the same. The question “can I calculate this during render?” applies regardless of which state management system you’re using.

The judgment that doesn’t come from the framework

What frustrated me most when I audited this codebase wasn’t finding the antipatterns — that was expected. It was realizing they were there because at the time nobody had a clear rule for deciding when to use useEffect. We used it as the default tool for “do something when something changes,” and that mental model is wrong from the start.

React 19 makes it easier to do the right thing: use() exists, useActionState exists, Server Components are more integrated. But without the judgment of when each tool applies, what happens is that the same problems just migrate to the new APIs.

My concrete take: before writing a useEffect, ask yourself whether what you want to do is (a) calculate something from existing state, (b) react to a user action, (c) load data, or (d) sync with something external to React. Only the last case justifies an effect. The first three have better solutions in React 19, and the official React documentation says so explicitly.

If you’re auditing a codebase and don’t know where to start, the same audit exercise applies to other patterns: the N+1 that shows up when you least expect it in Prisma has the same structure — a pattern that seems reasonable until you look at it with the right question.

Original source:

Por qué dejé de usar useEffect para sincronizar estado y qué uso ahora

Por qué dejé de usar useEffect para sincronizar estado y qué uso ahora

Cometí un error que, sospecho, la mayoría de los equipos que trabajan con React sigue cometiendo hoy: usé useEffect como una herramienta de sincronización de estado de propósito general. Si algo cambiaba y yo quería reaccionar, ahí estaba el efecto. Limpio, familiar, y — lo entendí tarde — completamente equivocado para la mayoría de esos casos.

No lo cuento para flagelante. Lo cuento porque cuando revisé sistemáticamente los efectos de una codebase en React 19, encontré exactamente cuatro categorías de mal uso repetidas. Cada una tiene una solución mejor. Y ninguna de las cuatro requiere hacks ni librerías externas.

Mi tesis: useEffect no está roto. Lo que está roto es el modelo mental con el que lo enseñamos — como si fuera el lugar natural para “hacer cosas cuando algo cambia”. React 19 pone herramientas mejores más cerca de la superficie, pero el criterio para elegir entre ellas sigue siendo necesario. Sin eso, React 19 solo te da nuevos lugares donde meter los mismos problemas.

useEffect para sincronizar estado: el antipatrón que nadie nombra

La documentación oficial de React tiene una página entera llamada “You Might Not Need an Effect” que debería leer cualquiera antes de escribir su primer efecto. No es un post de blog opinado — es documentación oficial del equipo de React. Y dice explícitamente que usar efectos para transformar datos durante el render es incorrecto.

El problema de fondo es que useEffect corre después del render. Cuando lo usás para derivar o sincronizar estado a partir de props o estado existente, estás provocando un ciclo extra: render → efecto → setState → render de nuevo. Eso es ruido visual, inconsistencias momentáneas y un grafo de dependencias que se vuelve imposible de seguir.

Mirá este patrón clásico que encontré repetido:

// ❌ useEffect para derivar estado — antipatrón documentado por React
const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);

useEffect(() => {
  // Cada vez que items cambia, filtramos
  // Esto genera un render extra innecesario
  setFilteredItems(items.filter(item => item.active));
}, [items]);

Este efecto parece razonable. No lo es. Genera dos renders cuando uno alcanza. Y si items viene de un fetch asíncrono, el estado intermedio donde filteredItems está desactualizado es completamente visible.

La solución es no tener filteredItems como estado en absoluto:

// ✅ Estado derivado — calculado durante el render, sin efecto
const [items, setItems] = useState<Item[]>([]);

// Se recalcula en cada render que involucra items
// Si el cálculo es costoso, useMemo es la herramienta, no useEffect
const filteredItems = items.filter(item => item.active);

Si el filtro es computacionalmente caro, useMemo con la dependencia correcta. Si es barato — y la mayoría lo son — ni eso. Cálculo directo durante el render.

Las 4 categorías concretas y qué las reemplaza

1. Estado derivado de props o estado existente → cálculo en render o useMemo

Ya lo vimos arriba. La regla práctica: si podés calcular algo a partir del estado o las props que ya tenés, no es estado nuevo. Es una función de lo que ya existe.

// ❌ Versión con efecto
const [user, setUser] = useState<User | null>(null);
const [displayName, setDisplayName] = useState('');

useEffect(() => {
  setDisplayName(user ? `${user.firstName} ${user.lastName}` : 'Invitado');
}, [user]);

// ✅ Versión correcta: derivado en línea
const displayName = user ? `${user.firstName} ${user.lastName}` : 'Invitado';

Parece trivial. En una codebase real con 30 componentes que hacen esto, el impacto acumulado en re-renders no es trivial.

2. Sincronización post-evento → event handlers directos

Otro patrón que encontré seguido: el efecto que observa un estado para “hacer algo cuando cambia”, pero ese cambio siempre viene de una interacción del usuario.

// ❌ useEffect que reacciona a un cambio que solo viene de un click
const [selectedId, setSelectedId] = useState<string | null>(null);

useEffect(() => {
  if (selectedId) {
    analytics.track('item_selected', { id: selectedId });
    loadDetails(selectedId);
  }
}, [selectedId]);

// ✅ El handler del evento sabe todo lo que necesita saber
const handleSelect = (id: string) => {
  setSelectedId(id);
  // La lógica que reacciona al evento va ACÁ, no en un efecto
  analytics.track('item_selected', { id });
  loadDetails(id);
};

La diferencia no es solo estética. Con el efecto, cualquier cambio en selectedId (incluso programático, incluso desde otro efecto) dispara la lógica. Con el handler, la intención es explícita: esto pasa cuando el usuario selecciona algo. Menos sorpresas, más control.

La documentación de React lo pone directo: si algo ocurre porque el usuario hizo algo, va en el event handler. Si algo ocurre porque el componente se mostró, va en un efecto. La confusión entre estos dos casos es la raíz de la mayoría de los bugs de useEffect que veo.

3. Fetch de datos → use() con Suspense en React 19, o Server Components

Esta es la categoría donde React 19 cambia más el juego. El fetch con useEffect es el patrón más copiado de internet y uno de los más problemáticos:

// ❌ El clásico useEffect para fetch — race conditions esperando suceder
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  // Sin cleanup, esto puede setear estado en un componente desmontado
  fetch(`/api/items/${id}`)
    .then(r => r.json())
    .then(setData)
    .finally(() => setLoading(false));
}, [id]);

Este código tiene una race condition clásica: si id cambia rápido, dos fetches corren en paralelo y el resultado puede llegar en cualquier orden. Se puede mitigar con cleanup, pero es boilerplate que la mayoría omite.

React 19 trae use() que integra con Suspense:

// ✅ React 19: use() + Suspense — sin useEffect, sin estado de loading manual
import { use, Suspense } from 'react';

// La promesa se crea fuera del componente o se pasa como prop
function ItemDetail({ itemPromise }: { itemPromise: Promise<Item> }) {
  // use() suspende el componente hasta que la promesa resuelve
  const item = use(itemPromise);

  return <div>{item.name}</div>;
}

// En el componente padre:
function Page({ id }: { id: string }) {
  // La promesa se crea aquí, React maneja el ciclo de vida
  const itemPromise = fetchItem(id); // función que devuelve Promise<Item>

  return (
    <Suspense fallback={<Skeleton />}>
      <ItemDetail itemPromise={itemPromise} />
    </Suspense>
  );
}

Pero si trabajás con App Router en Next.js 16, la respuesta más honesta es: usá Server Components para el fetch. El dato llega serializado al cliente, sin estado de carga, sin race conditions, sin useEffect. Para casos más complejos donde el fetch es dependiente de interacción del usuario, use() con Suspense es el camino.

Esto conecta con algo que documenté en el post sobre Prisma Server Actions en Next.js 16 — la frontera entre lo que corre en servidor y lo que corre en cliente cambia bastante qué patrones tienen sentido.

4. Transformaciones post-submit → Server Actions

La última categoría: el efecto que escucha el resultado de un submit para actualizar estado derivado, mostrar mensajes, o redireccionar.

// ❌ useEffect que observa el resultado de un submit
const [submitResult, setSubmitResult] = useState<Result | null>(null);
const [errorMessage, setErrorMessage] = useState('');

useEffect(() => {
  if (submitResult?.error) {
    setErrorMessage(submitResult.error.message);
  }
}, [submitResult]);

Con Server Actions en React 19 y el hook useActionState (anteriormente useFormState), esto colapsa en un patrón mucho más directo:

// ✅ useActionState — el estado del form y el resultado de la acción, integrados
import { useActionState } from 'react';

async function submitForm(prevState: State, formData: FormData): Promise<State> {
  'use server';
  // La acción corre en el servidor, devuelve el nuevo estado
  const result = await processForm(formData);
  if (!result.ok) {
    return { error: result.message };
  }
  return { success: true };
}

function MyForm() {
  const [state, action, isPending] = useActionState(submitForm, { error: null });

  return (
    <form action={action}>
      {/* Sin useEffect, sin estado extra, sin sincronización manual */}
      {state.error && <p className="error">{state.error}</p>}
      <button disabled={isPending}>Guardar</button>
    </form>
  );
}

El estado del form, el feedback de error y el loading están integrados sin un solo useEffect. No es magia — es que la responsabilidad está en el lugar correcto.

Los errores que persisten y los que desaparecen

Hay casos donde useEffect sí corresponde: suscripciones a stores externos, sincronización con APIs del DOM que no tenés control (un mapa de terceros, una librería de canvas), o setup/teardown de recursos que existen fuera del modelo de React.

Lo que desaparece con estos patrones:

  • Race conditions en fetch — use() y Server Components los eliminan estructuralmente
  • Renders intermedios inconsistentes — el estado derivado no tiene estado inconsistente porque no es estado
  • Grafos de efectos encadenados — cuando un efecto setea estado que dispara otro efecto, el debug es un infierno. Los event handlers lo cortan de raíz
  • Cleanup olvidado — si no hay efecto, no hay cleanup que olvidar

Lo que no desaparece:

  • Pensamiento sobre dependencias — useMemo también tiene un array de dependencias. use() requiere entender cómo React maneja las promesas. El criterio sigue siendo necesario.
  • Complejidad de Suspense — boundaries mal ubicados rompen la UX de maneras no obvias. No es un reemplazo automático.

FAQ

¿useEffect quedó obsoleto en React 19?

No. El equipo de React no lo deprecó ni lo marcó como legacy. Lo que cambió es que React 19 pone herramientas mejores más a mano para los casos donde useEffect era la única opción disponible. Para suscripciones, integración con APIs del DOM externas, y sincronización con sistemas fuera del modelo de React, useEffect sigue siendo correcto.

¿use() reemplaza a useEffect para todos los fetches?

Para fetches en componentes cliente que dependen de interacción del usuario, use() con Suspense es una alternativa concreta. Para fetches de datos iniciales en App Router, Server Components son la respuesta más directa — ni use() ni useEffect. La elección depende de si el dato puede resolverse en servidor o necesita esperar al cliente.

¿Cuándo usar useMemo vs cálculo directo para estado derivado?

Cálculo directo primero. useMemo solo cuando el cálculo es mediblemente costoso (sort/filter sobre arrays grandes, transformaciones complejas) y el profiler confirma que hay un problema. Agregar useMemo preventivamente es otra forma de sobre-ingeniería — tiene un costo de legibilidad y las dependencias también se pueden equivocar.

¿useActionState funciona sin Server Actions?

Sí. useActionState acepta cualquier función asíncrona, no solo Server Actions. Si preferís manejar el submit del lado del cliente, podés pasarle una función client-side. La integración con Server Actions es la más limpia en Next.js con App Router, pero no es un requisito.

¿Cómo migro una codebase existente? ¿Hay un path incremental?

El path más seguro es por categoría, no por archivo. Empezá identificando todos los useEffect que solo derivan estado — son los más seguros de migrar y los que dan más beneficio inmediato. Después los que reaccionan a eventos del usuario. Los fetches últimos, porque requieren decisiones sobre Suspense boundaries que afectan la UX.

¿Estos patrones cambian si uso Zustand, Jotai o Redux?

Parcialmente. Los stores externos resuelven el problema del estado global, pero el antipatrón de derivar estado dentro de un componente con useEffect aparece igual. La pregunta “¿puedo calcular esto durante el render?” aplica independientemente del sistema de estado que usés.

Conclusión: el criterio que no viene del framework

Lo que más me frustró cuando revisé esta codebase no fue encontrar los antipatrones — eso era esperable. Fue darme cuenta de que estaban ahí porque en su momento nadie tenía una regla clara para decidir cuándo usar useEffect. Lo usábamos como herramienta por defecto para “hacer algo cuando algo cambia”, y ese modelo mental es incorrecto desde el principio.

React 19 hace más fácil hacer lo correcto: use() existe, useActionState existe, los Server Components están más integrados. Pero sin el criterio de cuándo cada herramienta aplica, lo que pasa es que los mismos problemas migran a las nuevas APIs.

Mi postura concreta: antes de escribir un useEffect, preguntate si lo que querés hacer es (a) calcular algo a partir del estado existente, (b) reaccionar a una acción del usuario, (c) cargar datos, o (d) sincronizar con algo externo a React. Solo el último caso justifica un efecto. Los tres primeros tienen soluciones mejores en React 19, y la documentación oficial de React lo dice explícitamente.

Si estás revisando una codebase y no sabés por dónde empezar, el mismo ejercicio de auditoría sirve para otros patrones: el N+1 que aparece cuando menos lo esperás en Prisma tiene la misma estructura — un patrón que parece razonable hasta que lo mirás con la pregunta correcta.

Fuente original:

Migrating a Long-Running WordPress Site to Payload CMS (And All The Chaos That Came With It)

TL;DR for the skimmers:

  • Design the Payload schema before writing any migration code
  • Use a wpId field on every collection for idempotent upserts
  • Expect multiple media migration passes : the XML export won’t contain everything
  • The HTML-to-Lexical parser will dominate the timeline, budget accordingly
  • Separate migration scripts per content type is fine. Don’t build abstractions that are going to be deleted.

If you’ve ever opened a WordPress XML export, scrolled for a while, and quietly wondered who let this happen, this post is for you.

Content migrations from WordPress to a headless CMS look simple on paper. Export. Transform. Load. Three words, absolutely not innocent in practice. Most of the work isn’t moving data, it’s figuring out what the data means and where and how it should live in the new system.

Recently i had to migrate a long-running Saas Documnetaion WordPress site to Payload CMS. A site that had been through a commercial page builder like Divi, SEO plugin, a custom FAQ plugin, and at least three different image upload mechanisms over the years.

This post is about what worked, what didn’t, and the specific patterns that did it correctly when the script ran for the 30th time at 1 AM and finally produced clean output.

1. Design the Payload Schema Before Writing Any Migration Code

The migration script is throwaway code. The Payload collection is what the editorial team lives inside for the next several years. Spending a week on schema decisions before writing a single line of migration code is what keeps the project from becoming a rewrite of WordPress with extra steps.

Three decisions in this project carried more weight than everything else combined:

Model hierarchy with a self-referencing relationship, then derive the path from it. The documentation pages, policy pages, and integration catalog all had parent-child relationships. Some three or four levels deep. Each collection has a parent field pointing at the same collection, plus a read-only fullSlug and fullUrl that get recomputed in a beforeChange hook by walking up the parent chain:

// policy-pages collection — beforeChange hook
async ({ data, req }) => {
  if (!data?.slug) return data
  const fullSlug = await generateFullSlug(req.payload, data.slug, data.parent)
  data.fullSlug = fullSlug
  data.fullUrl = `/${fullSlug}`
  return data
}

async function generateFullSlug(payload, slug, parentId) {
  if (!parentId) return `policies/${slug}`
  const parent = await payload.findByID({
    collection: 'policy-pages', id: parentId, depth: 0,
  })
  if (parent?.fullSlug) return `${parent.fullSlug}/${slug}`
  return `policies/${slug}`
}

This does two things. First, renaming a parent page doesn’t break any of its children and the next save rewrites the full path. Second, the unique constraint goes on fullSlug (like docs/integrations/[provider]/setup), not on the individual slug. Multiple pages called “setup” under different parents is fine, because each resolves to a unique full path. WordPress handles this with friendly URL aliases. Payload handles it with a derived path. Both work, but the model needs to be committed to before the migration starts.

Stamp every migrated document with its WordPress ID. A wpId number field, hidden in the sidebar, read-only. Not for the editors but the migration script. This is what makes re-running the script safe instead of catastrophic. Section 2 covers the mechanics, but the schema decision needs to happen here: if the field doesn’t exist on the collection, none of the idempotency logic works.

Decide which WordPress structures become their own collections vs. blocks within a collection. The source site had landing pages, policy pages, documentation, an integration catalog, FAQs, and blog posts. These all lived in WordPress’s posts and pages tables, differentiated by parent IDs, custom post types, and template names. In Payload they became separate collections because each had fundamentally different fields: integration pages have a category relationship and platform-specific metadata. Documentation pages have a breadcrumbs array and cross-linking to related articles. Policy pages have none of that. FAQs are just a question/answer pair. If these had been shoe-horned into one pages collection with conditional fields, the admin panel would have been unusable.

2. Use a wpId Field for Idempotent Migrations

The migration script runs more than once. A lot more than once. Different subsets, different flags, different content types, recovery attempts after something breaks. In this project the documentation migration script alone ran dozens of times before everything settled.

Without a way to identify “is this WordPress post already in Payload?”, duplicates pile up on every run. Once is annoying. Thirty times is a cleanup project of its own.

The fix is a small in-memory map populated at the start of every run:

const wpIdToPayloadId = new Map<number, number>()

async function loadWpIdMap(payload: any) {
  const result = await payload.find({
    collection: 'docs',
    limit: 500,
    select: { id: true, wpId: true },
  })
  result.docs.forEach((doc) => {
    if (doc.wpId) wpIdToPayloadId.set(doc.wpId, Number(doc.id))
  })
}

Then during import:

const existingId = wpIdToPayloadId.get(wpPost.id)
if (existingId) {
  await payload.update({ collection: 'docs', id: existingId, data })
} else {
  await payload.create({
    collection: 'docs',
    data: { ...data, wpId: wpPost.id },
  })
}

The script is now idempotent. Same input produces the same result. This unlocks everything else: staging refreshes, retry logic, partial reruns when a parsing bug surfaces halfway through a batch.

Seven collections in this project ended up with wpId fields. Every migration script uses the same map-and-upsert pattern. It’s boring, and that’s the point.

3. Media Migration Won’t Be One Script

This is where the clean narrative “write a media resolver” falls apart.

WordPress had a complicated relationship with its own media library. The same image could appear in body HTML as a full CDN URL, a WordPress-generated size variant (screenshot-300x200.png), an artifact from a half-completed format migration (screenshot.png.webp), a protocol-less reference, or a relative path with no domain.

And that’s just the images that were in the media library. This particular site also had:

  • 60+ images uploaded directly to the server via FTP years ago, never registered in WordPress’s media library at all. They lived on disk but had no database entry. The XML export didn’t mention them.
  • ~100 theme images from the child theme’s CSS such as retina variants at 2x and 3x, icon sprites, background patterns. These were referenced in stylesheets, not in post content, but the new site needed them in Payload’s media collection so the storage adapter could serve them from S3.

That’s three separate media migration scripts. The first handles the XML export. The second is a hardcoded list of FTP URLs discovered by crawling the staging server. The third is a hardcoded list of theme asset paths. None of them shared code with each other beyond the Payload create call.

The first script also had a subtle bug that took weeks to surface. Retina images like [email protected] and [email protected] were all failing silently. The WordPress XML stored their attachment URLs with %40 (URL-encoded @) in the filename: icon%402x.png. The migration script extracted the filename with path.basename(new URL(...).pathname) which doesn’t decode URL encoding. Payload received icon%402x.png instead of [email protected]. Thirteen images affected. The fix was a one-liner (decodeURIComponent), but finding it required inspecting the XML by hand and wondering why only images with @ in the name were broken. This is the kind of bug that only shows up when you stop looking at the script and start looking at the data.

In this project the content-body media resolver (used during HTML-to-Lexical conversion) was a lookup function, not a migration script.

function findMediaId(src: string): number | null {
  const filename = extractFilename(src)
  const baseName = getBaseFilename(filename) // strips .png.webp → base

  // 1. Exact base match
  if (mediaFilenameToId.has(baseName)) return mediaFilenameToId.get(baseName)!

  // 2. Full lowercase filename
  if (mediaFilenameToId.has(filename.toLowerCase())) return mediaFilenameToId.get(filename.toLowerCase())!

  // 3. Partial match (handles WP size variants like -300x200)
  for (const [key, id] of mediaFilenameToId) {
    if (key.startsWith(baseName + '-') || baseName.startsWith(key + '-')) return id
  }

  // 4. Substring match (catches double-extension edge cases)
  for (const [key, id] of mediaFilenameToId) {
    if (key.startsWith(baseName + '.') || key === baseName) return id
  }

  missingMediaSrcs.add(`${filename}::${currentPageSlug}`)
  return null
}

A tiered lookup: exact match, then lowercase, then strip the WordPress size suffix, then substring fallback. Anything that still misses goes into a missingMediaSrcs set that prints at the end of the run, grouped by page slug.

An important decision: don’t fail the document write when a media reference can’t be resolved. Some source HTML will point at images that aren’t in the export at all. If the script throws on missing media, the whole migration stops on the first batch. Logging and continuing produces a clean triage list at the end instead of a hard halt at the start.

4. The HTML-to-Lexical Body Parser Is Where Most of the Time Goes

WordPress stores rich content as HTML. Payload wants a structured Lexical JSON tree. The documentation parser alone ended up at ~900 lines. Here’s why:

Page builder wrappers everywhere. The source HTML wasn’t clean semantic markup. It was wrapped in multiple layers of page builder containers like section > row > column > module > text-inner > actual content. Sometimes there were additional wrapper divs with custom CSS classes. The parser had to recognize and recurse through all of them without treating them as content.

HTML entity decoding. Smart quotes, &nbsp; sequences, numeric entities, etc. What’s worth noting is that two separate implementations of decodeHtmlEntities() ended up in the codebase because policy pages and documentation were migrated as separate scripts with slightly different entity handling. Copy-paste creeps in when scripts are written independently.

Page-builder shortcodes that need to become Payload blocks. A toggle module becomes a CollapsibleBlock. A styled button with a specific CSS class becomes a CustomLink block. A row of grouped tabbed links for different integration methods becomes a multi-link block. Each of these has different Lexical node shapes.

Inline images that need to become MediaBlock references. WordPress drops <img> tags anywhere: inside paragraphs, inside list items, inside blockquotes. Payload’s Lexical editor represents images as block-level nodes with a relationship to a media document. The parser has to extract inline images, resolve them through the media lookup, and emit them as separate nodes.

The pattern that works: use JSDOM to walk the source HTML, classify each node, emit the corresponding Lexical structure. Be explicit and boring. Don’t try to be clever.

The central processor ended up looking like this:

function processBlockNodes(container: Element, isNested: boolean = false): any[] {
  const children: any[] = []
  let inlineBuffer: any[] = []

  function flushBuffer() {
    if (inlineBuffer.length === 0) return
    children.push(makeParagraph(inlineBuffer))
    inlineBuffer = []
  }

  function processNode(node: ChildNode) {
    // ... classify and emit
    // Headings → createHeading()
    // Paragraphs → extract images first, then processInlineNodes()
    // Lists → createList() (with nested list and trailing content handling)
    // Blockquotes → createQuote() + extract trailing images
    // Page builder wrappers → recurse
    // FAQ accordions → processFaqList()
    // CTA buttons → createCustomLinkSingle()
    // Bare text → accumulate in inlineBuffer
  }

  container.childNodes.forEach(processNode)
  flushBuffer()
  return children
}

The inlineBuffer pattern is worth calling out. WordPress HTML sometimes has bare text nodes between block elements. Lexical requires all text to live inside a paragraph node. The buffer collects loose text and flushes it into a paragraph whenever a real block element comes along.

The classification rules are specific to the source site. In this project, the documentation parser handled things the policy page parser didn’t (FAQ accordions, tabbed link blocks) and vice versa (tables, back-navigation links). Each script handles its own content type.

One thing the code above doesn’t convey: the parser wasn’t designed correctly on the first pass, or the second, or the third. The first dry-run produced 9 media blocks across 80 pages. That was clearly wrong. The source HTML had images everywhere. The fix for images inside <p> tags bumped it to 160. Then it turned out blocks nested inside <li> elements were being silently skipped. Then support buttons inside <p> tags weren’t becoming CustomLink blocks. Then querySelector was only grabbing the first page builder text block on pages that had several. Each dry-run revealed a new class of missed content.

The parser wasn’t designed but discovered, one bug at a time.

For example, one bug from this phase didn’t surface until much later, which is the lesson in itself. The parser generated Lexical node IDs with Date.now() plus a short random hex suffix. On fast iterations, Date.now() returns the same millisecond twice, and the random suffix occasionally collided producing duplicate node IDs across a document. The migrated content rendered fine on the front-end, so nothing looked wrong. But opening those documents in the Payload admin editor crashed it with a React duplicate-key error. A parser bug, surfacing as a CMS crash. The fix was a proper unique ID generator; finding it meant tracing a React error in the admin panel all the way back to a migration script that had already been deleted.

Speaking of which.

5. Accept That You’ll Have Separate Scripts Per Content Type

This is something I chose not to gloss over, and it’s worth being honest about. The project didn’t end up with one unified migration system. I ended up with twelve separate scripts:

  • migrate-media.ts: XML export media
  • migrate-media-ftp.ts: 60+ FTP images not in WordPress
  • migrate-theme-media.ts: ~100 theme images from the child theme
  • migrate-clients.ts
  • migrate-categories.ts
  • migrate-platforms.ts: taxonomy terms for the integration catalog
  • migrate-pages.ts: root landing pages (page builder hero detection)
  • migrate-policy-pages.ts: 5 policy pages with table support
  • migrate-docs.ts: ~65 hierarchical documentation pages (the big one)
  • migrate-integrations.ts: ~100 integration catalog pages
  • migrate-faqs.ts: FAQ custom post type from XML
  • migrate-bespoke.ts: one hand-crafted page that didn’t fit the general parser

Each has its own CLI flags (--dry-run, --limit, --slug, --verbose, --force-update), its own HTML-to-Lexical converter (ranging from “strip all tags and split by newlines” for landing pages to the full 900-line parser for documentation), and its own idempotency logic.

A planned unified CLI entry point (scripts/index.ts) never got built. Running individual scripts worked fine and less time consuming as well.

The fragmentation wasn’t a failure of planning. It was a consequence of the “design the schema first” advice. Each content type had different fields, different block types, different HTML structures, and different parent/child relationships. The documentation needed hierarchy sorting and related-articles linking. The policy pages needed back-navigation stripping and table support. The integration catalog needed category taxonomy mapping. The FAQ custom post type had to be parsed from XML, not JSON.

Didn’t want to optimize for dry. Feels like a trap!

6. Lexical Block Constraints Need a Sanitization Pass

This one caught me off guard, and it cost a day of mysterious validation errors.

Lexical blocks have rules about what they can contain. A CollapsibleBlock might allow h2 through h6, paragraphs, lists, media, and horizontal rules but not h1, blockquotes, or nested collapsibles. Real source HTML doesn’t always follow those rules.

The documentation parser hits this constantly because the WordPress FAQ plugin generates accordion sections, and those sections contain whatever HTML the editor put in them including other FAQ sections. Nest an accordion inside an accordion and Payload rejects the write.

The fix is a sanitization pass that runs between parsing and writing:

function sanitizeForCollapsible(lexical: any): any {
  if (!lexical?.root?.children) return lexical

  const sanitized: any[] = []
  for (const node of lexical.root.children) {
    // h1 → h2
    if (node.type === 'heading' && node.tag === 'h1') {
      sanitized.push({ ...node, tag: 'h2' })
      continue
    }
    // blockquote → paragraphs
    if (node.type === 'quote') {
      const paras = (node.children || []).filter((c) => c.type === 'paragraph')
      sanitized.push(...(paras.length > 0 ? paras : [makeParagraph(extractText(node))]))
      continue
    }
    // nested collapsible → flatten content into parent
    if (node.type === 'block' && node.fields?.blockType === 'collapsible') {
      sanitized.push(...(node.fields?.content?.root?.children || []))
      continue
    }
    sanitized.push(node)
  }

  return { root: { ...lexical.root, children: sanitized } }
}

Without this, roughly a quarter of documents can fail validation at write time. The error messages don’t indicate which nested rule was violated, which is most of what makes this expensive to debug the first time.

7. WordPress URL Aliases Need Their Own Mapping…sometimes

This took a full afternoon to figure out and it’s not obvious.

WordPress has a concept of “friendly” URL aliases. A documentation page might live at /docs/integrations/provider-a/setup-guide-full but also be reachable at /docs/provider-a/setup. The short URL is what appears in navigation links and “related articles” sections within other pages.

Payload doesn’t have this concept. A document has one slug, one parent chain, one fullSlug. So when the source HTML contains a related-articles section with links like /docs/provider-a/setup, that path can’t just be looked up in Payload. It doesn’t exist.

The fix was a hardcoded lookup table mapping WordPress friendly URLs to the actual Payload fullSlug:

const wpFriendlyToFullSlug: Record<string, string> = {
  'docs/provider-a/setup': 'docs/integrations/provider-a/setup-guide-full',
  'docs/provider-b/webhooks': 'docs/integrations/provider-b/configuring-webhook-endpoints',
  'docs/api/v1': 'docs/api/v1/getting-started',
  'docs/api/v2': 'docs/api/v2/schema-reference',
  // ... 30 more entries
}

This table was built by manually inspecting which WordPress page each friendly URL pointed to. It’s not glamorous work. But without it, related-articles links would silently fail to resolve, and the migrated site would have broken cross-linking.

The SEO plugin’s breadcrumb schema data was useful here as it was a structured BreadcrumbList JSON with absolute URLs and titles, which helped verify that the mapping was correct.

8. Build Dry-Run Mode From Day One

The highest-leverage decision is adding proper CLI flags at the start of the project, not at the end:

tsx migrate-docs.ts --dry-run
tsx migrate-docs.ts --dry-run --limit=5
tsx migrate-docs.ts --slug=getting-started
tsx migrate-docs.ts --force-update

These take an hour to add per script. They save days of cleanup over the course of the project. Once they exist, make --dry-run the default. Running the script accidentally should be harmless.

The --slug flag is particularly valuable when debugging a single problematic page. Running the parser against one document and inspecting the full Lexical output without touching the database saves 20 minutes of debugging.

Running the migration in dry-run mode with verbose logging also turned out to be useful beyond catching script bugs. Inspecting every document on its way through the parser surfaced things worth flagging:

  • Slug collisions across different parents, usually legitimate. Three pages named “setup” under different parent integrations is fine when the unique constraint is on fullSlug.
  • Orphaned drafts with no slug, reachable only by ?page_id=12345 URLs.
  • Media references without matching files in the export.
  • One page whose HTML was unique enough that it warranted its own bespoke migration script rather than trying to bend the general parser to handle it.

The dry-run output ended up serving as the definitive content inventory.

9. Sort by Hierarchy Before Migrating

When content is hierarchical, parents need to be migrated before children.

The documentation pages formed a tree. The wpIdToPayloadId map needs the parent’s Payload ID before setting the child’s parent relationship field. Process children first and the lookup fails, leaving orphaned documents.

The fix was a topological sort that guarantees parents come before children:

function sortPagesByHierarchy(pages: WordPressPage[]): WordPressPage[] {
  const sorted: WordPressPage[] = []
  const remaining = [...pages]
  const added = new Set<number>()
  const allPageIds = new Set(pages.map((p) => p.id))

  while (remaining.length > 0) {
    const beforeLength = remaining.length

    for (let i = remaining.length - 1; i >= 0; i--) {
      const page = remaining[i]
      const parentInSet = allPageIds.has(page.parent)
      const parentMigrated = added.has(page.parent)

      if (!parentInSet || parentMigrated) {
        sorted.push(page)
        added.add(page.id)
        remaining.splice(i, 1)
      }
    }

    // Circular dependency guard
    if (remaining.length === beforeLength) {
      console.warn(`Circular dependency, force-adding ${remaining.length} pages`)
      sorted.push(...remaining)
      break
    }
  }

  return sorted
}

The circular dependency guard is there because one page in the export referenced a parent that didn’t exist in the set. Without it, the sort loops forever.

Closing Thoughts

The chaos isn’t in the code. It’s in the decisions and the modeling. Which content types become their own collections? Which URL conventions carry over? What counts as a “block” versus a “field”? The migration script just translates those decisions one at a time.

PS – I didn’t write all of this code alone, either. Claude and GLM were on the team for most of it, especially through the parser layer where pattern-matching twelve variations of the same shortcode is exactly the tedious work AI is good at. The architectural calls stayed mine; the boilerplate didn’t. If a deeper post on that workflow would be useful consisting what I prompted, where it failed, where it shaved off days versus quietly added them back, say so in the comments and I’ll write it up.

Keep your dry-runs greedy and your logs verbose.