[{"data":1,"prerenderedAt":904},["ShallowReactive",2],{"blog-conventions-for-ai-coding-agents":3,"related-conventions-for-ai-coding-agents":66},{"id":4,"title":5,"author":6,"body":7,"category":6,"date":50,"description":51,"draft":52,"extension":53,"image":6,"meta":54,"navigation":56,"path":57,"seo":58,"stem":59,"tags":60,"__hash__":65},"blog\u002Fblog\u002Fconventions-for-ai-coding-agents.md","Write your conventions for the agent, not the next hire",null,{"type":8,"value":9,"toc":46},"minimark",[10,14,17,20,23,26,34,37,40,43],[11,12,13],"p",{},"We have all worked in a codebase with three ways to do the same thing. One the founder wrote in a hurry, one a contractor left behind, one that is just whatever the last person copied off the internet. You learn to read all three. It is annoying but survivable, because a human can hold a contradiction in their head and pick the right pattern by smell.",[11,15,16],{},"A coding agent cannot smell anything.",[11,18,19],{},"Give an agent a codebase with three ways to fetch data and it will cheerfully add a fourth. Not because it is dumb, but because it has no reason to prefer yours. It pattern-matches against everything it has ever seen, and everything it has ever seen includes every bad tutorial ever published. The drift is not a one-time mess you clean up later. It compounds. Every file the agent writes becomes context for the next file, so one sloppy week teaches the agent to keep being sloppy.",[11,21,22],{},"The usual answer to this is a long prompt. You paste your preferences at the start of every session and hope they survive. They do not. The model loses them, the session resets, you forget to paste them, and you are back to three patterns and counting.",[11,24,25],{},"The better answer is to write the rules down where the agent will find them on its own.",[11,27,28,29,33],{},"That is what ",[30,31,32],"code",{},"AGENTS.md"," is. Not documentation for the next human hire, though it works for that too. It is a contract the agent reads before it works. One documented way to access data. One way to handle secrets. One way to gate an authenticated route. One way to add a migration, a locale string, a Stripe price. The agent does not have to guess your taste, because your taste is written down in a place it actually looks.",[11,35,36],{},"Writing it down is half the job. The other half is handing the agent the current facts instead of a stale memory of them. An agent that guesses your database columns will guess wrong, and confidently. So BoiledPlate ships read-only MCP servers that let the agent look at your real Supabase schema, the actual Nuxt UI component docs, and your Stripe test data while it builds. It is the difference between an assistant who read the manual once and one who has the manual open on the desk.",[11,38,39],{},"There is one more layer, and it is the one I am most attached to. Vendor skills. The Supabase team, the Stripe team, and the Nuxt UI team all have opinions about how their tools should be used, and those opinions live in skills the agent can consult. I vendor them into the repo, hash-locked, refreshed by a weekly CI pull request so they do not silently rot. On top of those I wrote one of my own for Nuxt 4 patterns, with every rule anchored to a real file in the repo so the agent can go read the example instead of trusting a description of it.",[11,41,42],{},"Put together, the agent building in BoiledPlate is not improvising your conventions from memory. It is reading them, checking them against your live schema, and following skills written by the people who made the tools. The output stays consistent in month three because consistency was designed in, not nagged in.",[11,44,45],{},"The old instinct says write clean code and document it for whoever comes next. That instinct is right. It is just aimed at the wrong reader now. The next contributor to your codebase is an agent, and it will follow your rules exactly as well as you wrote them down.",{"title":47,"searchDepth":48,"depth":48,"links":49},"",3,[],"2026-06-08","An agent cannot smell a codebase. Give it three ways to do one thing and it adds a fourth. Here is how BoiledPlate keeps agent output consistent past month three.",false,"md",{"slug":55},"conventions-for-ai-coding-agents",true,"\u002Fblog\u002Fconventions-for-ai-coding-agents",{"title":5,"description":51},"blog\u002Fconventions-for-ai-coding-agents",[61,62,63,64],"ai-agents","claude-code","conventions","developer-experience","NsQDMtlYDClnjq1hfj5qm_qUO25Y8pNl1Gi3j6Qd3rc",[67,161,265],{"id":68,"title":69,"author":6,"body":70,"category":6,"date":146,"description":147,"draft":52,"extension":53,"image":6,"meta":148,"navigation":56,"path":150,"seo":151,"stem":152,"tags":153,"__hash__":160},"blog\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate.md","What it actually takes to wire up a Nuxt, Supabase and Stripe SaaS",{"type":8,"value":71,"toc":138},[72,75,78,81,86,89,92,96,99,108,112,115,118,122,125,129,132,135],[11,73,74],{},"If you are about to build a SaaS, the stack writes itself these days. Nuxt for the app, Supabase for the database and auth, Stripe for payments, Resend for email. It is a good stack. I have shipped on it more than once and I would pick it again tomorrow.",[11,76,77],{},"Then you open an empty project and remember that picking the stack was the easy decision. Wiring four services into something that does not leak data or lose payments is the part that takes the weeks.",[11,79,80],{},"Here is what each piece actually asks of you, roughly in the order it tends to bite.",[82,83,85],"h2",{"id":84},"supabase-auth-is-the-fast-part-rls-is-the-part-you-forget","Supabase: auth is the fast part, RLS is the part you forget",[11,87,88],{},"Getting a login form working with Supabase takes an afternoon. Email and password, maybe Google OAuth, a session, a redirect. Feels done.",[11,90,91],{},"It is not done. The moment you have more than one user, every table needs row-level security, and you need it from the first migration, not bolted on after launch when you are already holding real data. RLS is not optional polish. It is the only thing standing between user A and user B's rows. Get a policy wrong and the bug does not throw an error, it quietly serves the wrong data to the wrong person. You also want password recovery that does not reveal whether an email exists, route protection that lives in middleware instead of scattered redirects, and generated types so you stop guessing at column names.",[82,93,95],{"id":94},"stripe-the-webhook-is-the-product-the-checkout-is-the-easy-bit","Stripe: the webhook is the product, the checkout is the easy bit",[11,97,98],{},"Everyone wires the checkout first because it is satisfying. Money goes in, you get redirected, you feel like a founder.",[11,100,101,102,107],{},"The redirect is the least trustworthy thing in your whole system. The real billing state has to come from Stripe's webhooks, signature-verified and idempotent, because the browser will close the tab and the network will deliver the same event twice. Then there is the part most starters skip: a refund has to revoke access on its own, and plan limits have to be enforced in the database, not hidden in the UI. None of this is hard once. It is a lot of small correct decisions, and any one of them wrong is a billing bug in production. I wrote about ",[103,104,106],"a",{"href":105},"\u002Fblog\u002Fstripe-webhooks-source-of-truth","the webhook trap in more detail here"," if you want the longer version.",[82,109,111],{"id":110},"resend-simple-to-send-annoying-to-send-exactly-once","Resend: simple to send, annoying to send exactly once",[11,113,114],{},"Sending one email with Resend is three lines. The trouble starts when a Stripe retry fires a second payment-confirmation email, or a flaky request makes you send the welcome email twice. You want a single server-side utility, sends that are idempotent for anything a user can trigger, and every line of those emails living as a locale key so a German customer does not get an English receipt.",[11,116,117],{},"Which brings up the thing that quietly doubles the work on everything above.",[82,119,121],{"id":120},"nuxt-is-the-glue-and-i18n-is-the-tax-on-doing-it-right","Nuxt is the glue, and i18n is the tax on doing it right",[11,123,124],{},"Every string in all of this, the auth labels, the validation messages, the toasts, the Stripe error states, the email bodies, has to exist in every language you support. Tack i18n on at the end and you will spend a week hunting hardcoded strings. Build it in from the first component and it is just a habit. Shipping four languages on day one is a very different project from shipping one and promising the rest.",[82,126,128],{"id":127},"so-how-long-is-all-of-that","So how long is all of that",[11,130,131],{},"For me, the first time, weeks. Not because any single piece is clever, but because the correct version of each one hides about five non-obvious decisions, and you only learn which five by getting them wrong.",[11,133,134],{},"That is the entire reason BoiledPlate exists. It is a Nuxt 4 SaaS starter on exactly this stack, Supabase, Stripe and Resend, already wired the right way. RLS on every table from the first migration. The Stripe webhook as the source of truth, refund-aware, with limits in the policies. One idempotent email utility, localized. Four languages shipped, not promised. You point a coding agent at it, answer nine questions, and the thing reshapes itself to your product instead of you reshaping yourself to it.",[11,136,137],{},"You can absolutely wire this yourself. The stack is good and the docs are all there. I just think the second time you do it, you start wondering why you are paying the same tax twice.",{"title":47,"searchDepth":48,"depth":48,"links":139},[140,142,143,144,145],{"id":84,"depth":141,"text":85},2,{"id":94,"depth":141,"text":95},{"id":110,"depth":141,"text":111},{"id":120,"depth":141,"text":121},{"id":127,"depth":141,"text":128},"2026-06-09","Picking a Nuxt + Supabase + Stripe + Resend stack is the easy decision. Here is the wiring each piece really demands, and where a SaaS starter saves you weeks.",{"slug":149},"nuxt-supabase-stripe-saas-boilerplate","\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate",{"title":69,"description":147},"blog\u002Fnuxt-supabase-stripe-saas-boilerplate",[154,155,156,157,158,159],"nuxt","supabase","stripe","resend","saas","boilerplate","O9aBZHsolIkxENJU_ebdFE9p0IW2dowSQQ-IXutuW_M",{"id":162,"title":163,"author":6,"body":164,"category":6,"date":146,"description":256,"draft":52,"extension":53,"image":6,"meta":257,"navigation":56,"path":259,"seo":260,"stem":261,"tags":262,"__hash__":264},"blog\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase.md","How to set up Stripe subscriptions in a Nuxt + Supabase app",{"type":8,"value":165,"toc":248},[166,169,172,176,197,201,207,211,218,222,231,235,238,242,245],[11,167,168],{},"Subscriptions are the part of a SaaS where the demo and the reality diverge most. The happy path, where a user clicks subscribe, pays, and gets access, is maybe a fifth of the work. The other four fifths is everything that happens after, and Stripe plus Supabase have a clean division of labor for it if you set things up in the right order.",[11,170,171],{},"Here is the order that has worked for me on a Nuxt + Supabase app.",[82,173,175],{"id":174},"_1-define-your-plans-in-one-place","1. Define your plans in one place",[11,177,178,179,182,183,182,186,189,190,192,193,196],{},"Before any Stripe code, decide what your plans are and give each a slug your app understands, like ",[30,180,181],{},"free",", ",[30,184,185],{},"pro",[30,187,188],{},"team",". Keep the slugs in your codebase and keep the Stripe price IDs in an environment map next to them. The reason to separate the two: your app should reason about ",[30,191,185],{},", not about ",[30,194,195],{},"price_1Q2x...",", and you will have different price IDs in test and live mode anyway. One source of truth for the slugs, one env map for the IDs.",[82,198,200],{"id":199},"_2-checkout-is-a-redirect-nothing-more","2. Checkout is a redirect, nothing more",[11,202,203,204],{},"Create a Stripe Checkout session on the server, hand the URL to the browser, redirect. Do not record anything about the subscription at this point. The checkout session succeeding tells you the user started paying, not that they are entitled to anything yet. Resist the urge to flip a flag on the success page. ",[103,205,206],{"href":105},"That instinct is the single most common billing bug, and here is why.",[82,208,210],{"id":209},"_3-the-webhook-is-where-subscriptions-actually-live","3. The webhook is where subscriptions actually live",[11,212,213,214,217],{},"This is the heart of it. Stripe sends events for the whole lifecycle: subscription created, renewed, payment failed, cancelled, refunded. Your webhook handler, signature-verified and idempotent, is the only thing that writes subscription state to Supabase. When ",[30,215,216],{},"customer.subscription.updated"," says the plan changed, you update the row. When it says the payment is past due, you reflect that. The database is a mirror of what Stripe believes, never a guess your frontend made.",[82,219,221],{"id":220},"_4-turn-subscription-state-into-access","4. Turn subscription state into access",[11,223,224,225,227,228,230],{},"Now you have a row in Supabase that says this user is on ",[30,226,185],{}," and the subscription is active. Access control reads from that row. The important part, again, is where you enforce it: in your row-level security policies, so a ",[30,229,185],{},"-only table is genuinely unreachable for a free user, not just visually hidden. The UI can hide buttons for nice UX, but the policy is what makes the limit real.",[82,232,234],{"id":233},"_5-give-them-the-customer-portal","5. Give them the customer portal",[11,236,237],{},"You do not want to build cancellation, card updates, and invoice history yourself. Stripe's customer portal does all of it. Generate a portal session on the server, redirect the user, and let your webhook pick up whatever they change there. One less set of screens to build and keep correct.",[82,239,241],{"id":240},"the-shape-of-it","The shape of it",[11,243,244],{},"Plans in config. Checkout as a dumb redirect. Webhook as the only writer of state. RLS as the enforcer. Portal for self-service. Get those five right and subscriptions stop being scary, because every piece has exactly one job.",[11,246,247],{},"If you would rather not assemble all five by hand, this is the billing setup BoiledPlate ships by default on a Nuxt 4 + Supabase stack. Subscriptions, one-time, or none, chosen when the starter sets itself up, with the webhook, the RLS limits and the portal already wired. Think of it as the version of this post where someone already made the five decisions, so you just build your product on top.",{"title":47,"searchDepth":48,"depth":48,"links":249},[250,251,252,253,254,255],{"id":174,"depth":141,"text":175},{"id":199,"depth":141,"text":200},{"id":209,"depth":141,"text":210},{"id":220,"depth":141,"text":221},{"id":233,"depth":141,"text":234},{"id":240,"depth":141,"text":241},"The five decisions behind Stripe subscriptions on a Nuxt + Supabase stack: plans in config, checkout, webhooks, RLS-enforced access, and the customer portal.",{"slug":258},"stripe-subscriptions-nuxt-supabase","\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase",{"title":163,"description":256},"blog\u002Fstripe-subscriptions-nuxt-supabase",[156,263,154,155,158],"subscriptions","WgV2n4beSVMVXw3OM_Wp6Q6RGGHUo73mCDuPwU0S7FM",{"id":266,"title":267,"author":6,"body":268,"category":6,"date":891,"description":892,"draft":52,"extension":53,"image":6,"meta":893,"navigation":56,"path":895,"seo":896,"stem":897,"tags":898,"__hash__":903},"blog\u002Fblog\u002Fthe-page-that-returned-200-on-the-server-and-500-in-the-browser-a-json-ld-tempor.md","200 from curl, 500 in Chrome: the JSON-LD bug that lived in source order",{"type":8,"value":269,"toc":884},[270,277,280,284,298,545,560,575,627,636,640,654,675,698,723,733,737,752,762,768,772,785,788,808,817,821,827,844,869,880],[11,271,272,273,276],{},"Here is a debugging situation that feels impossible the first time you meet it. ",[30,274,275],{},"curl https:\u002F\u002Fboiledplate.ai\u002F"," returns a clean 200 with the full HTML, the SoftwareApplication JSON-LD, the FAQPage schema, everything. Open the same URL in Chrome and you get Nuxt's red client-side 500 page a beat after the first paint. Then, as a bonus, a second exception fires every time you click away from the page.",[11,278,279],{},"Same code. Same URL. Same request. The server says fine, the browser says dead. We spent a while staring at that before the shape of it clicked, so here is the whole thing: what broke, why the two runtimes disagreed, and the change that fixed it (which was moving code, not writing any).",[82,281,283],{"id":282},"the-page-in-question","The page in question",[11,285,286,289,290,293,294,297],{},[30,287,288],{},"app\u002Fpages\u002Findex.vue"," is our landing page. Near the top of ",[30,291,292],{},"\u003Cscript setup>"," it builds a pile of localized data with ",[30,295,296],{},"computed()",": the feature grid, the pricing \"what's included\" list, the three how-it-works steps, and the FAQ. The FAQ one looks like this:",[299,300,304],"pre",{"className":301,"code":302,"language":303,"meta":47,"style":47},"language-ts shiki shiki-themes material-theme-lighter github-light github-dark","const faqItems = computed(() => ([\n 'included', 'stack', 'delivery', 'agent', 'whichAgents',\n 'updates', 'oneTime', 'license', 'refund',\n] as const).map(key => ({\n label: t(`landing.faq.items.${key}.q`),\n content: t(`landing.faq.items.${key}.a`),\n})))\n","ts",[30,305,306,341,394,432,468,506,537],{"__ignoreMap":47},[307,308,311,315,319,323,327,331,335,338],"span",{"class":309,"line":310},"line",1,[307,312,314],{"class":313},"sbsja","const",[307,316,318],{"class":317},"s_hVV"," faqItems",[307,320,322],{"class":321},"smGrS"," =",[307,324,326],{"class":325},"sGLFI"," computed",[307,328,330],{"class":329},"su5hD","(",[307,332,334],{"class":333},"sP7_E","()",[307,336,337],{"class":313}," =>",[307,339,340],{"class":329}," ([\n",[307,342,343,347,351,354,357,359,362,364,366,368,371,373,375,377,380,382,384,386,389,391],{"class":309,"line":141},[307,344,346],{"class":345},"sjJ54"," '",[307,348,350],{"class":349},"s_sjI","included",[307,352,353],{"class":345},"'",[307,355,356],{"class":333},",",[307,358,346],{"class":345},[307,360,361],{"class":349},"stack",[307,363,353],{"class":345},[307,365,356],{"class":333},[307,367,346],{"class":345},[307,369,370],{"class":349},"delivery",[307,372,353],{"class":345},[307,374,356],{"class":333},[307,376,346],{"class":345},[307,378,379],{"class":349},"agent",[307,381,353],{"class":345},[307,383,356],{"class":333},[307,385,346],{"class":345},[307,387,388],{"class":349},"whichAgents",[307,390,353],{"class":345},[307,392,393],{"class":333},",\n",[307,395,396,398,401,403,405,407,410,412,414,416,419,421,423,425,428,430],{"class":309,"line":48},[307,397,346],{"class":345},[307,399,400],{"class":349},"updates",[307,402,353],{"class":345},[307,404,356],{"class":333},[307,406,346],{"class":345},[307,408,409],{"class":349},"oneTime",[307,411,353],{"class":345},[307,413,356],{"class":333},[307,415,346],{"class":345},[307,417,418],{"class":349},"license",[307,420,353],{"class":345},[307,422,356],{"class":333},[307,424,346],{"class":345},[307,426,427],{"class":349},"refund",[307,429,353],{"class":345},[307,431,393],{"class":333},[307,433,435,438,442,445,448,451,454,456,460,462,465],{"class":309,"line":434},4,[307,436,437],{"class":329},"] ",[307,439,441],{"class":440},"sVHd0","as",[307,443,444],{"class":313}," const",[307,446,447],{"class":329},")",[307,449,450],{"class":333},".",[307,452,453],{"class":325},"map",[307,455,330],{"class":329},[307,457,459],{"class":458},"s99_P","key",[307,461,337],{"class":313},[307,463,464],{"class":329}," (",[307,466,467],{"class":333},"{\n",[307,469,471,475,478,481,483,486,489,492,494,497,500,502,504],{"class":309,"line":470},5,[307,472,474],{"class":473},"skxfh"," label",[307,476,477],{"class":333},":",[307,479,480],{"class":325}," t",[307,482,330],{"class":329},[307,484,485],{"class":345},"`",[307,487,488],{"class":349},"landing.faq.items.",[307,490,491],{"class":345},"${",[307,493,459],{"class":329},[307,495,496],{"class":345},"}",[307,498,499],{"class":349},".q",[307,501,485],{"class":345},[307,503,447],{"class":329},[307,505,393],{"class":333},[307,507,509,512,514,516,518,520,522,524,526,528,531,533,535],{"class":309,"line":508},6,[307,510,511],{"class":473}," content",[307,513,477],{"class":333},[307,515,480],{"class":325},[307,517,330],{"class":329},[307,519,485],{"class":345},[307,521,488],{"class":349},[307,523,491],{"class":345},[307,525,459],{"class":329},[307,527,496],{"class":345},[307,529,530],{"class":349},".a",[307,532,485],{"class":345},[307,534,447],{"class":329},[307,536,393],{"class":333},[307,538,540,542],{"class":309,"line":539},7,[307,541,496],{"class":333},[307,543,544],{"class":329},")))\n",[11,546,547,548,551,552,555,556,559],{},"Further down, a ",[30,549,550],{},"useHead"," call emits two ",[30,553,554],{},"application\u002Fld+json"," script tags. The SoftwareApplication block carries the offer price. The FAQPage block is built by mapping over ",[30,557,558],{},"faqItems.value",", so Google sees the same questions the accordion renders.",[11,561,562,563,565,566,570,571,574],{},"In the version that shipped the bug, those two halves were in the wrong order. The ",[30,564,550],{}," block sat ",[567,568,569],"strong",{},"above"," the ",[30,572,573],{},"const faqItems"," declaration. Schematically:",[299,576,578],{"className":301,"code":577,"language":303,"meta":47,"style":47},"useHead({ \u002F* ... maps over faqItems.value ... *\u002F }) \u002F\u002F line ~80\n\u002F\u002F ...a bunch of other consts...\nconst faqItems = computed(\u002F* ... *\u002F) \u002F\u002F line ~120\n",[30,579,580,602,607],{"__ignoreMap":47},[307,581,582,584,586,589,593,596,599],{"class":309,"line":310},[307,583,550],{"class":325},[307,585,330],{"class":329},[307,587,588],{"class":333},"{",[307,590,592],{"class":591},"sutJx"," \u002F* ... maps over faqItems.value ... *\u002F",[307,594,595],{"class":333}," }",[307,597,598],{"class":329},") ",[307,600,601],{"class":591},"\u002F\u002F line ~80\n",[307,603,604],{"class":309,"line":141},[307,605,606],{"class":591},"\u002F\u002F ...a bunch of other consts...\n",[307,608,609,611,613,615,617,619,622,624],{"class":309,"line":48},[307,610,314],{"class":313},[307,612,318],{"class":317},[307,614,322],{"class":321},[307,616,326],{"class":325},[307,618,330],{"class":329},[307,620,621],{"class":591},"\u002F* ... *\u002F",[307,623,598],{"class":329},[307,625,626],{"class":591},"\u002F\u002F line ~120\n",[11,628,629,630,632,633,635],{},"A ",[30,631,550],{}," that reads a ",[30,634,314],{}," declared forty lines later. Read top to bottom, nothing screams. And on the server, nothing does scream: the prerender is green.",[82,637,639],{"id":638},"why-the-browser-was-the-only-one-that-complained","Why the browser was the only one that complained",[11,641,642,643,645,646,650,651,653],{},"The instinct is to suspect ",[30,644,550],{}," of being broken. It is not. The disagreement comes from ",[647,648,649],"em",{},"when"," ",[30,652,550],{}," decides to read the object you hand it, and that timing is different on each runtime.",[11,655,656,657,660,661,664,665,668,669,671,672,674],{},"During SSR, head input is resolved ",[567,658,659],{},"lazily",". Nuxt collects the entries and serializes them at the end of the render, once the entire ",[30,662,663],{},"setup"," function has finished running. By the time anything actually reads ",[30,666,667],{},"faqItems",", the ",[30,670,314],{}," was initialized long ago. The reference points at a live computed, the FAQPage JSON serializes, the response is a 200. Source order between the ",[30,673,550],{}," call and the declaration is invisible, because the read happens after both have run.",[11,676,677,678,680,681,684,685,688,689,691,692,650,695,697],{},"On the client, ",[30,679,550],{}," resolves its input ",[567,682,683],{},"synchronously",", inside a ",[30,686,687],{},"watchEffect"," that runs as part of setup. That effect fires the instant ",[30,690,550],{}," is called. With the block sitting above the declaration, that instant is ",[647,693,694],{},"before",[30,696,573],{}," has executed.",[11,699,700,701,703,704,707,708,710,711,714,715,718,719,722],{},"And that is the trap, because ",[30,702,314],{}," and ",[30,705,706],{},"let"," bindings sit in the temporal dead zone from the top of their scope until the exact line that declares them. Touching ",[30,709,667],{}," inside that window does not give you ",[30,712,713],{},"undefined",", the way a hoisted ",[30,716,717],{},"var"," or function would. It throws ",[30,720,721],{},"ReferenceError: Cannot access 'faqItems' before initialization",". Hard stop. The synchronous client effect walked directly into the TDZ, the error propagated through hydration, and Nuxt swapped in its 500 page.",[11,724,725,726,729,730,732],{},"So the portable lesson is not about this file. It is: ",[567,727,728],{},"the client evaluates head input eagerly, at the call site; the server evaluates it lazily, after setup completes."," Any source-order dependency between a ",[30,731,550],{}," call and a binding it references is a no-op on the server and a fatal error in the browser.",[82,734,736],{"id":735},"the-second-stack-trace-was-a-decoy","The second stack trace was a decoy",[11,738,739,740,743,744,747,748,751],{},"There was a tell that something deeper was off: a ",[647,741,742],{},"second"," exception, a ",[30,745,746],{},"TypeError"," inside ",[30,749,750],{},"entry.dispose()",", every time you navigated away.",[11,753,754,755,757,758,761],{},"It falls straight out of the first. When the synchronous effect throws partway through the ",[30,756,550],{}," call, the call never returns, so the head entry object it was meant to produce never gets assigned to its slot. Later, when the component unmounts and unhead iterates its entries to tear them down, it reaches that empty slot and calls ",[30,759,760],{},".dispose()"," on nothing.",[11,763,764,765,767],{},"Two stack traces on one page baits you into hunting two bugs. There was one. The disposal ",[30,766,746],{}," was a downstream symptom, and it vanished the moment the TDZ error was gone.",[82,769,771],{"id":770},"the-fix-was-a-cut-and-paste","The fix was a cut and paste",[11,773,774,775,777,778,781,782,784],{},"Move the ",[30,776,550],{}," block below every binding it touches. No logic changed; the diff is just relocating the head config (and the ",[30,779,780],{},"numericPrice"," computed the SoftwareApplication price uses) so it follows ",[30,783,667],{}," and friends instead of preceding them.",[11,786,787],{},"We also left a comment in the file, and we meant it:",[299,789,791],{"className":301,"code":790,"language":303,"meta":47,"style":47},"\u002F\u002F NOTE: this block must stay BELOW every binding it references (faqItems!).\n\u002F\u002F useHead resolves its input synchronously on the client, so a reference to\n\u002F\u002F a binding declared later in setup throws a TDZ error that breaks the page.\n",[30,792,793,798,803],{"__ignoreMap":47},[307,794,795],{"class":309,"line":310},[307,796,797],{"class":591},"\u002F\u002F NOTE: this block must stay BELOW every binding it references (faqItems!).\n",[307,799,800],{"class":309,"line":141},[307,801,802],{"class":591},"\u002F\u002F useHead resolves its input synchronously on the client, so a reference to\n",[307,804,805],{"class":309,"line":48},[307,806,807],{"class":591},"\u002F\u002F a binding declared later in setup throws a TDZ error that breaks the page.\n",[11,809,810,811,813,814,816],{},"That comment is load-bearing. In a normal ",[30,812,292],{},", the order of declarations carries no meaning past hoisting, so the next person tidying the file would cheerfully float the ",[30,815,550],{}," call back up to group it with the other head config and reintroduce the exact crash. The comment exists to stop that.",[82,818,820],{"id":819},"what-we-actually-took-away","What we actually took away",[11,822,823,826],{},[567,824,825],{},"A server-only smoke test can pass on a corpse."," We prerender the landing page and we could have asserted \"200, contains the JSON-LD\" all day. It would have stayed green while real browsers ate a 500, because the prerender runs the server path, which is the one path where this bug does not exist. If a route hydrates, something has to hydrate it in a browser-like environment before \"tested\" means anything.",[11,828,829,832,833,836,837,839,840,843],{},[567,830,831],{},"\"Works on the server, breaks on the client\" is almost always a timing word, not a logic word."," The same expression runs at two different moments relative to the rest of setup. TDZ is one way that bites. Reading ",[30,834,835],{},"window",", or a template ref, or anything DOM-shaped before it exists is another. When you see the split, ask ",[647,838,649],{}," a thing runs before you ask ",[647,841,842],{},"what"," it does.",[11,845,846,851,852,854,855,857,858,860,861,864,865,868],{},[567,847,848,850],{},[30,849,314],{}," did us a favor."," Had ",[30,853,667],{}," been a ",[30,856,717],{},", the client would have read ",[30,859,713],{},", then ",[30,862,863],{},".map"," would have thrown a mushy \"cannot read properties of undefined,\" and we would have chased the symptom instead of the cause. The ",[30,866,867],{},"ReferenceError"," named the binding and pointed at the ordering. Strictness turned a quiet hazard into a loud, specific one.",[11,870,871,872,875,876,450],{},"The honest part is that this is invisible going in and obvious in hindsight. The setup function reads cleanly top to bottom, and nothing about ",[30,873,874],{},"useHead({ ... })"," advertises that it evaluates its argument at the call site on one runtime and after setup on the other. If you like SEO-shaped bugs that only surface once the built output is the thing being served, we wrote up another one: ",[103,877,879],{"href":878},"\u002Fblog\u002Four-blog-canonically-pointed-at-localhost","our blog that canonically pointed at localhost",[881,882,883],"style",{},"html pre.shiki code .sbsja, html code.shiki .sbsja{--shiki-light:#9C3EDA;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s_hVV, html code.shiki .s_hVV{--shiki-light:#90A4AE;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .smGrS, html code.shiki .smGrS{--shiki-light:#39ADB5;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sGLFI, html code.shiki .sGLFI{--shiki-light:#6182B8;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .su5hD, html code.shiki .su5hD{--shiki-light:#90A4AE;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sP7_E, html code.shiki .sP7_E{--shiki-light:#39ADB5;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sjJ54, html code.shiki .sjJ54{--shiki-light:#39ADB5;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s_sjI, html code.shiki .s_sjI{--shiki-light:#91B859;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVHd0, html code.shiki .sVHd0{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#D73A49;--shiki-default-font-style:inherit;--shiki-dark:#F97583;--shiki-dark-font-style:inherit}html pre.shiki code .s99_P, html code.shiki .s99_P{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#E36209;--shiki-default-font-style:inherit;--shiki-dark:#FFAB70;--shiki-dark-font-style:inherit}html pre.shiki code .skxfh, html code.shiki .skxfh{--shiki-light:#E53935;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sutJx, html code.shiki .sutJx{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#6A737D;--shiki-default-font-style:inherit;--shiki-dark:#6A737D;--shiki-dark-font-style:inherit}",{"title":47,"searchDepth":48,"depth":48,"links":885},[886,887,888,889,890],{"id":282,"depth":141,"text":283},{"id":638,"depth":141,"text":639},{"id":735,"depth":141,"text":736},{"id":770,"depth":141,"text":771},{"id":819,"depth":141,"text":820},"2026-06-06","Our landing page passed every server check and crashed on hydration. The bug was the position of one line, and only the browser could see it.",{"slug":894},"the-page-that-returned-200-on-the-server-and-500-in-the-browser-a-json-ld-tempor","\u002Fblog\u002Fthe-page-that-returned-200-on-the-server-and-500-in-the-browser-a-json-ld-tempor",{"title":267,"description":892},"blog\u002Fthe-page-that-returned-200-on-the-server-and-500-in-the-browser-a-json-ld-tempor",[154,899,900,901,902],"ssr","hydration","debugging","javascript","Xsu7KmAJMx3QpsP0BBIT6sBprrPToKwa_uyh6q1LE0c",1780964587329]