[{"data":1,"prerenderedAt":720},["ShallowReactive",2],{"blog-our-blog-canonically-pointed-at-localhost":3,"related-our-blog-canonically-pointed-at-localhost":470},{"id":4,"title":5,"author":6,"body":7,"category":6,"date":456,"description":457,"draft":372,"extension":458,"image":6,"meta":459,"navigation":372,"path":461,"seo":462,"stem":463,"tags":464,"__hash__":469},"blog\u002Fblog\u002Four-blog-canonically-pointed-at-localhost.md","Our blog told Google its canonical URL was localhost:3000",null,{"type":8,"value":9,"toc":449},"minimark",[10,14,69,72,136,143,146,151,162,170,181,206,210,213,232,238,242,271,277,281,287,333,340,405,415,427,431,434,445],[11,12,13],"p",{},"We shipped a Markdown blog, ran a production build, opened the generated HTML to spot-check it, and found this stamped into every post:",[15,16,21],"pre",{"className":17,"code":18,"language":19,"meta":20,"style":20},"language-html shiki shiki-themes material-theme-lighter github-light github-dark","\u003Clink rel=\"canonical\" href=\"http:\u002F\u002Flocalhost:3000\u002Fblog\u002Fhello-world\">\n","html","",[22,23,24],"code",{"__ignoreMap":20},[25,26,29,33,37,41,44,48,52,54,57,59,61,64,66],"span",{"class":27,"line":28},"line",1,[25,30,32],{"class":31},"sP7_E","\u003C",[25,34,36],{"class":35},"sQzsp","link",[25,38,40],{"class":39},"s9AJx"," rel",[25,42,43],{"class":31},"=",[25,45,47],{"class":46},"sjJ54","\"",[25,49,51],{"class":50},"s_sjI","canonical",[25,53,47],{"class":46},[25,55,56],{"class":39}," href",[25,58,43],{"class":31},[25,60,47],{"class":46},[25,62,63],{"class":50},"http:\u002F\u002Flocalhost:3000\u002Fblog\u002Fhello-world",[25,65,47],{"class":46},[25,67,68],{"class":31},">\n",[11,70,71],{},"The JSON-LD right below it agreed:",[15,73,77],{"className":74,"code":75,"language":76,"meta":20,"style":20},"language-json shiki shiki-themes material-theme-lighter github-light github-dark","\"mainEntityOfPage\": { \"@type\": \"WebPage\", \"@id\": \"http:\u002F\u002Flocalhost:3000\u002Fblog\u002Fhello-world\" }\n","json",[22,78,79],{"__ignoreMap":20},[25,80,81,83,86,88,92,95,99,103,105,108,110,113,115,118,120,123,125,127,129,131,133],{"class":27,"line":28},[25,82,47],{"class":46},[25,84,85],{"class":50},"mainEntityOfPage",[25,87,47],{"class":46},[25,89,91],{"class":90},"su5hD",": ",[25,93,94],{"class":31},"{",[25,96,98],{"class":97},"s39Yj"," \"",[25,100,102],{"class":101},"sseR_","@type",[25,104,47],{"class":97},[25,106,107],{"class":31},":",[25,109,98],{"class":46},[25,111,112],{"class":50},"WebPage",[25,114,47],{"class":46},[25,116,117],{"class":31},",",[25,119,98],{"class":97},[25,121,122],{"class":101},"@id",[25,124,47],{"class":97},[25,126,107],{"class":31},[25,128,98],{"class":46},[25,130,63],{"class":50},[25,132,47],{"class":46},[25,134,135],{"class":31}," }\n",[11,137,138,139,142],{},"A self-canonical to ",[22,140,141],{},"localhost"," is close to the worst signal you can hand a crawler for a page you actually want indexed. It names a host Google can never reach and declares \"the real version of this page lives there, not here.\" Nothing 404s, nothing throws, the page looks perfect in a browser. It just quietly tells the index to ignore itself.",[11,144,145],{},"Here is how a dev-only URL ended up in a production artifact, why the first fix did not hold, and the rule we landed on.",[147,148,150],"h2",{"id":149},"prerendering-freezes-whatever-the-build-happened-to-see","Prerendering freezes whatever the build happened to see",[11,152,153,154,157,158,161],{},"The blog runs on ",[22,155,156],{},"@nuxt\u002Fcontent",", and the posts are prerendered. That is the right call for this stack: on Vercel's serverless runtime the sqlite driver that ",[22,159,160],{},"queryCollection"," needs does not exist at request time, so we generate the HTML at build time and serve static files. No cold-start query, no per-request cost.",[11,163,164,165,169],{},"The cost is somewhere else. Prerendering runs your page code exactly once, during the build, and writes the result to disk. Every value the page reads gets soldered into that file. For static content that is the point. For anything that depends on ",[166,167,168],"em",{},"where the build ran",", it is a landmine.",[11,171,172,173,176,177,180],{},"Our canonical was built from ",[22,174,175],{},"runtimeConfig.public.siteUrl",". The word \"runtime\" is the whole trap. On a server-rendered request that value genuinely is per-request, read fresh from the deploy environment. A prerendered page has no request. It reads ",[22,178,179],{},"siteUrl"," once, at build, and that single resolution is what every future visitor receives.",[11,182,183,184,187,188,191,192,195,196,199,200,202,203,205],{},"In local dev ",[22,185,186],{},"NUXT_PUBLIC_SITE_URL"," defaults to ",[22,189,190],{},"http:\u002F\u002Flocalhost:3000",". So a production build kicked off from a laptop, with a dev ",[22,193,194],{},".env"," sitting next to it, wrote ",[22,197,198],{},"localhost:3000"," into every canonical and every JSON-LD ",[22,201,122],{}," on the blog. Our landing page never showed the bug because it is server-rendered, not prerendered. It reads ",[22,204,179],{}," on each request, and in production that resolves to the real origin. The two render modes diverged on the exact value that matters most for SEO.",[147,207,209],{"id":208},"first-fix-stop-reading-a-runtime-value","First fix: stop reading a runtime value",[11,211,212],{},"The diagnosis felt complete. Stop reading a per-request config value for something that gets frozen at build time. Read a build-time constant instead.",[11,214,215,216,219,220,223,224,227,228,231],{},"Nuxt's site config looked perfect for that. ",[22,217,218],{},"useSiteConfig().url"," comes from the ",[22,221,222],{},"site.url"," field in ",[22,225,226],{},"nuxt.config",", resolved once at build rather than per request. We pointed the blog pages at it, the canonical now read ",[22,229,230],{},"https:\u002F\u002Fboiledplate.ai",", and we moved on.",[11,233,234,235,237],{},"Then the next production build from the same machine produced the same ",[22,236,141],{}," canonical.",[147,239,241],{"id":240},"why-the-build-constant-was-still-the-dev-url","Why the build constant was still the dev URL",[11,243,244,245,247,248,251,252,254,255,257,258,260,261,263,264,267,268,270],{},"The wrong assumption hid one layer down: where ",[22,246,222],{}," got ",[166,249,250],{},"its"," value. We had wired ",[22,253,226],{},"'s ",[22,256,222],{}," to fall back to the same ",[22,259,186],{}," env var. So ",[22,262,218],{}," was a build constant in the strict sense, but it was a build constant computed from the dev environment variable. On a local ",[22,265,266],{},"vercel --prod"," build that variable is still ",[22,269,190],{},". We had swapped which function read the value without touching the poisoned input feeding it.",[11,272,273,274,276],{},"That is the part worth keeping: \"frozen at build time\" and \"always production\" are not the same property. A value can be a perfectly good build constant and still be wrong, because what it froze was your laptop's dev config. The canonical URL belongs to a small set of values that must equal the production origin no matter where the build runs, a teammate's machine, a ",[22,275,266],{}," off a branch, CI, anywhere. Anything an environment can override fails that bar, including a constant derived from something an environment can override.",[147,278,280],{"id":279},"the-fix-that-stuck-one-literal-one-source-of-truth","The fix that stuck: one literal, one source of truth",[11,282,283,284,107],{},"So we stopped deriving the production origin and just stated it. A single file, ",[22,285,286],{},"shared\u002Fsite.ts",[15,288,292],{"className":289,"code":290,"language":291,"meta":20,"style":20},"language-ts shiki shiki-themes material-theme-lighter github-light github-dark","\u002F\u002F The canonical production origin: the single source of truth for the\n\u002F\u002F public site URL literal.\nexport const SITE_ORIGIN = 'https:\u002F\u002Fboiledplate.ai'\n","ts",[22,293,294,300,306],{"__ignoreMap":20},[25,295,296],{"class":27,"line":28},[25,297,299],{"class":298},"sutJx","\u002F\u002F The canonical production origin: the single source of truth for the\n",[25,301,303],{"class":27,"line":302},2,[25,304,305],{"class":298},"\u002F\u002F public site URL literal.\n",[25,307,309,313,317,321,325,328,330],{"class":27,"line":308},3,[25,310,312],{"class":311},"sVHd0","export",[25,314,316],{"class":315},"sbsja"," const",[25,318,320],{"class":319},"s_hVV"," SITE_ORIGIN",[25,322,324],{"class":323},"smGrS"," =",[25,326,327],{"class":46}," '",[25,329,230],{"class":50},[25,331,332],{"class":46},"'\n",[11,334,335,254,337,339],{},[22,336,226],{},[22,338,222],{}," reads this constant, and the blog pages import it directly for canonical and JSON-LD:",[15,341,343],{"className":289,"code":342,"language":291,"meta":20,"style":20},"import { SITE_ORIGIN } from '#shared\u002Fsite'\n\n\u002F\u002F Canonical\u002FJSON-LD use the production origin constant. These pages are\n\u002F\u002F prerendered, and a local `vercel --prod` build would otherwise bake the\n\u002F\u002F dev siteUrl.\nconst siteUrl = SITE_ORIGIN\n",[22,344,345,368,374,379,385,391],{"__ignoreMap":20},[25,346,347,350,353,355,358,361,363,366],{"class":27,"line":28},[25,348,349],{"class":311},"import",[25,351,352],{"class":31}," {",[25,354,320],{"class":90},[25,356,357],{"class":31}," }",[25,359,360],{"class":311}," from",[25,362,327],{"class":46},[25,364,365],{"class":50},"#shared\u002Fsite",[25,367,332],{"class":46},[25,369,370],{"class":27,"line":302},[25,371,373],{"emptyLinePlaceholder":372},true,"\n",[25,375,376],{"class":27,"line":308},[25,377,378],{"class":298},"\u002F\u002F Canonical\u002FJSON-LD use the production origin constant. These pages are\n",[25,380,382],{"class":27,"line":381},4,[25,383,384],{"class":298},"\u002F\u002F prerendered, and a local `vercel --prod` build would otherwise bake the\n",[25,386,388],{"class":27,"line":387},5,[25,389,390],{"class":298},"\u002F\u002F dev siteUrl.\n",[25,392,394,397,400,402],{"class":27,"line":393},6,[25,395,396],{"class":315},"const",[25,398,399],{"class":319}," siteUrl",[25,401,324],{"class":323},[25,403,404],{"class":319}," SITE_ORIGIN\n",[11,406,407,408,411,412,414],{},"It lives in ",[22,409,410],{},"shared\u002F"," because that directory is visible to both the app and server TypeScript projects, the same reason our generated database types live there. One literal, imported by everything that emits a public-facing absolute URL on a prerendered page. There is no environment variable anywhere in that path, so there is nothing for a stray dev ",[22,413,194],{}," to poison.",[11,416,417,418,420,421,423,424,426],{},"Runtime SSR keeps reading ",[22,419,175],{},", which the deploy environment still overrides per environment. That is correct: those pages render per request and should reflect where they are actually running. Only the prerendered, must-always-be-production URLs use the hardcoded origin. We confirmed the fix by reading the prerendered HTML from a local production build and checking that both the canonical and the ",[22,422,122],{}," resolved to ",[22,425,230],{},".",[147,428,430],{"id":429},"the-takeaway","The takeaway",[11,432,433],{},"If a page is prerendered, treat every absolute URL it emits as frozen at build time and ask one question: would this value still be correct if the build ran on my laptop? For a canonical tag the answer has to be yes, unconditionally, which means it cannot come from anything an environment can change. A runtime config value fails that test. A value derived from a runtime config value also fails it, and that is the layer that fooled us into thinking we were done.",[11,435,436,437,440,441,444],{},"The blog ships alongside a sitemap fed from the same build-time route scan and ",[22,438,439],{},"BlogPosting"," JSON-LD on every post, so the same literal now backs all three. If you are wiring up the same stack, the canonical-on-prerender bug is the one that will not appear in dev and will not throw in production. Open the generated HTML and read the ",[22,442,443],{},"\u003Clink rel=\"canonical\">"," with your own eyes before you trust it.",[446,447,448],"style",{},"html pre.shiki code .sP7_E, html code.shiki .sP7_E{--shiki-light:#39ADB5;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sQzsp, html code.shiki .sQzsp{--shiki-light:#E53935;--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .s9AJx, html code.shiki .s9AJx{--shiki-light:#9C3EDA;--shiki-default:#6F42C1;--shiki-dark:#B392F0}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 .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 .su5hD, html code.shiki .su5hD{--shiki-light:#90A4AE;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s39Yj, html code.shiki .s39Yj{--shiki-light:#39ADB5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sseR_, html code.shiki .sseR_{--shiki-light:#9C3EDA;--shiki-default:#005CC5;--shiki-dark:#79B8FF}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}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 .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}",{"title":20,"searchDepth":308,"depth":308,"links":450},[451,452,453,454,455],{"id":149,"depth":302,"text":150},{"id":208,"depth":302,"text":209},{"id":240,"depth":302,"text":241},{"id":279,"depth":302,"text":280},{"id":429,"depth":302,"text":430},"2026-06-09","A prerendered Nuxt blog baked the dev site URL into every og:url and JSON-LD @id. Why it took two fixes, and the one-line rule that finally stuck.","md",{"slug":460},"our-blog-canonically-pointed-at-localhost","\u002Fblog\u002Four-blog-canonically-pointed-at-localhost",{"title":5,"description":457},"blog\u002Four-blog-canonically-pointed-at-localhost",[465,466,467,468],"nuxt","seo","prerendering","ssg","QYUNgIVCxGYydnY6dJ38FbseeLHGcu8xpdDHVk1Yzdw",[471,562,666],{"id":472,"title":473,"author":6,"body":474,"category":6,"date":456,"description":548,"draft":549,"extension":458,"image":6,"meta":550,"navigation":372,"path":552,"seo":553,"stem":554,"tags":555,"__hash__":561},"blog\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate.md","What it actually takes to wire up a Nuxt, Supabase and Stripe SaaS",{"type":8,"value":475,"toc":541},[476,479,482,485,489,492,495,499,502,511,515,518,521,525,528,532,535,538],[11,477,478],{},"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,480,481],{},"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,483,484],{},"Here is what each piece actually asks of you, roughly in the order it tends to bite.",[147,486,488],{"id":487},"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,490,491],{},"Getting a login form working with Supabase takes an afternoon. Email and password, maybe Google OAuth, a session, a redirect. Feels done.",[11,493,494],{},"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.",[147,496,498],{"id":497},"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,500,501],{},"Everyone wires the checkout first because it is satisfying. Money goes in, you get redirected, you feel like a founder.",[11,503,504,505,510],{},"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 ",[506,507,509],"a",{"href":508},"\u002Fblog\u002Fstripe-webhooks-source-of-truth","the webhook trap in more detail here"," if you want the longer version.",[147,512,514],{"id":513},"resend-simple-to-send-annoying-to-send-exactly-once","Resend: simple to send, annoying to send exactly once",[11,516,517],{},"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,519,520],{},"Which brings up the thing that quietly doubles the work on everything above.",[147,522,524],{"id":523},"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,526,527],{},"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.",[147,529,531],{"id":530},"so-how-long-is-all-of-that","So how long is all of that",[11,533,534],{},"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,536,537],{},"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,539,540],{},"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":20,"searchDepth":308,"depth":308,"links":542},[543,544,545,546,547],{"id":487,"depth":302,"text":488},{"id":497,"depth":302,"text":498},{"id":513,"depth":302,"text":514},{"id":523,"depth":302,"text":524},{"id":530,"depth":302,"text":531},"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.",false,{"slug":551},"nuxt-supabase-stripe-saas-boilerplate","\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate",{"title":473,"description":548},"blog\u002Fnuxt-supabase-stripe-saas-boilerplate",[465,556,557,558,559,560],"supabase","stripe","resend","saas","boilerplate","O9aBZHsolIkxENJU_ebdFE9p0IW2dowSQQ-IXutuW_M",{"id":563,"title":564,"author":6,"body":565,"category":6,"date":456,"description":657,"draft":549,"extension":458,"image":6,"meta":658,"navigation":372,"path":660,"seo":661,"stem":662,"tags":663,"__hash__":665},"blog\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase.md","How to set up Stripe subscriptions in a Nuxt + Supabase app",{"type":8,"value":566,"toc":649},[567,570,573,577,598,602,608,612,619,623,632,636,639,643,646],[11,568,569],{},"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,571,572],{},"Here is the order that has worked for me on a Nuxt + Supabase app.",[147,574,576],{"id":575},"_1-define-your-plans-in-one-place","1. Define your plans in one place",[11,578,579,580,583,584,583,587,590,591,593,594,597],{},"Before any Stripe code, decide what your plans are and give each a slug your app understands, like ",[22,581,582],{},"free",", ",[22,585,586],{},"pro",[22,588,589],{},"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 ",[22,592,586],{},", not about ",[22,595,596],{},"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.",[147,599,601],{"id":600},"_2-checkout-is-a-redirect-nothing-more","2. Checkout is a redirect, nothing more",[11,603,604,605],{},"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. ",[506,606,607],{"href":508},"That instinct is the single most common billing bug, and here is why.",[147,609,611],{"id":610},"_3-the-webhook-is-where-subscriptions-actually-live","3. The webhook is where subscriptions actually live",[11,613,614,615,618],{},"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 ",[22,616,617],{},"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.",[147,620,622],{"id":621},"_4-turn-subscription-state-into-access","4. Turn subscription state into access",[11,624,625,626,628,629,631],{},"Now you have a row in Supabase that says this user is on ",[22,627,586],{}," 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 ",[22,630,586],{},"-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.",[147,633,635],{"id":634},"_5-give-them-the-customer-portal","5. Give them the customer portal",[11,637,638],{},"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.",[147,640,642],{"id":641},"the-shape-of-it","The shape of it",[11,644,645],{},"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,647,648],{},"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":20,"searchDepth":308,"depth":308,"links":650},[651,652,653,654,655,656],{"id":575,"depth":302,"text":576},{"id":600,"depth":302,"text":601},{"id":610,"depth":302,"text":611},{"id":621,"depth":302,"text":622},{"id":634,"depth":302,"text":635},{"id":641,"depth":302,"text":642},"The five decisions behind Stripe subscriptions on a Nuxt + Supabase stack: plans in config, checkout, webhooks, RLS-enforced access, and the customer portal.",{"slug":659},"stripe-subscriptions-nuxt-supabase","\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase",{"title":564,"description":657},"blog\u002Fstripe-subscriptions-nuxt-supabase",[557,664,465,556,559],"subscriptions","WgV2n4beSVMVXw3OM_Wp6Q6RGGHUo73mCDuPwU0S7FM",{"id":667,"title":668,"author":6,"body":669,"category":6,"date":707,"description":708,"draft":549,"extension":458,"image":6,"meta":709,"navigation":372,"path":711,"seo":712,"stem":713,"tags":714,"__hash__":719},"blog\u002Fblog\u002Fconventions-for-ai-coding-agents.md","Write your conventions for the agent, not the next hire",{"type":8,"value":670,"toc":705},[671,674,677,680,683,686,693,696,699,702],[11,672,673],{},"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,675,676],{},"A coding agent cannot smell anything.",[11,678,679],{},"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,681,682],{},"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,684,685],{},"The better answer is to write the rules down where the agent will find them on its own.",[11,687,688,689,692],{},"That is what ",[22,690,691],{},"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,694,695],{},"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,697,698],{},"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,700,701],{},"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,703,704],{},"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":20,"searchDepth":308,"depth":308,"links":706},[],"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.",{"slug":710},"conventions-for-ai-coding-agents","\u002Fblog\u002Fconventions-for-ai-coding-agents",{"title":668,"description":708},"blog\u002Fconventions-for-ai-coding-agents",[715,716,717,718],"ai-agents","claude-code","conventions","developer-experience","NsQDMtlYDClnjq1hfj5qm_qUO25Y8pNl1Gi3j6Qd3rc",1780964587329]