[{"data":1,"prerenderedAt":912},["ShallowReactive",2],{"blog-vercel-web-analytics-nuxt-4-analytics-package-breaks-typecheck":3,"related-vercel-web-analytics-nuxt-4-analytics-package-breaks-typecheck":449},{"id":4,"title":5,"author":6,"body":7,"category":6,"date":434,"description":435,"draft":182,"extension":436,"image":6,"meta":437,"navigation":182,"path":439,"seo":440,"stem":441,"tags":442,"__hash__":448},"blog\u002Fblog\u002Fvercel-web-analytics-nuxt-4-analytics-package-breaks-typecheck.md","Vercel Web Analytics on Nuxt 4: @vercel\u002Fanalytics breaks nuxt typecheck",null,{"type":8,"value":9,"toc":427},"minimark",[10,23,26,259,262,267,278,289,300,309,313,316,331,338,350,353,357,364,374,386,389,393,403,406,410,420,423],[11,12,13,14,18,19,22],"p",{},"The file with the highest comment-to-code ratio in this repo is ",[15,16,17],"code",{},"app\u002Fplugins\u002Fvercel-analytics.client.ts",". Six lines of comment, eight lines of code. The comment is long because the code looks like something you would reject in review: a hand-rolled script tag where an official npm package exists. The comment is there so the next agent or human who opens the file does not \"fix\" it by reinstalling ",[15,20,21],{},"@vercel\u002Fanalytics",".",[11,24,25],{},"Here is the whole file, comment included:",[27,28,33],"pre",{"className":29,"code":30,"language":31,"meta":32,"style":32},"language-ts shiki shiki-themes material-theme-lighter github-light github-dark","\u002F\u002F Vercel Web Analytics, injected as the plain insights script rather than\n\u002F\u002F via @vercel\u002Fanalytics, whose Nuxt build declares a stale `vue-router@^4`\n\u002F\u002F peer that conflicts with Nuxt 4's vue-router 5 (it un-hoists vue-router\n\u002F\u002F and breaks `nuxt typecheck`). The bare script tracks pageviews and SPA\n\u002F\u002F navigations on its own; Vercel serves it only on its own infra, so we\n\u002F\u002F skip it on localhost to avoid a dev-time 404.\nexport default defineNuxtPlugin(() => {\n const host = window.location.hostname\n if (host === 'localhost' || host === '127.0.0.1') return\n\n useHead({\n script: [{ src: '\u002F_vercel\u002Finsights\u002Fscript.js', defer: true }],\n })\n})\n","ts","",[15,34,35,44,50,56,62,68,74,103,130,177,184,195,243,251],{"__ignoreMap":32},[36,37,40],"span",{"class":38,"line":39},"line",1,[36,41,43],{"class":42},"sutJx","\u002F\u002F Vercel Web Analytics, injected as the plain insights script rather than\n",[36,45,47],{"class":38,"line":46},2,[36,48,49],{"class":42},"\u002F\u002F via @vercel\u002Fanalytics, whose Nuxt build declares a stale `vue-router@^4`\n",[36,51,53],{"class":38,"line":52},3,[36,54,55],{"class":42},"\u002F\u002F peer that conflicts with Nuxt 4's vue-router 5 (it un-hoists vue-router\n",[36,57,59],{"class":38,"line":58},4,[36,60,61],{"class":42},"\u002F\u002F and breaks `nuxt typecheck`). The bare script tracks pageviews and SPA\n",[36,63,65],{"class":38,"line":64},5,[36,66,67],{"class":42},"\u002F\u002F navigations on its own; Vercel serves it only on its own infra, so we\n",[36,69,71],{"class":38,"line":70},6,[36,72,73],{"class":42},"\u002F\u002F skip it on localhost to avoid a dev-time 404.\n",[36,75,77,81,84,88,92,96,100],{"class":38,"line":76},7,[36,78,80],{"class":79},"sVHd0","export",[36,82,83],{"class":79}," default",[36,85,87],{"class":86},"sGLFI"," defineNuxtPlugin",[36,89,91],{"class":90},"su5hD","(",[36,93,95],{"class":94},"sP7_E","()",[36,97,99],{"class":98},"sbsja"," =>",[36,101,102],{"class":94}," {\n",[36,104,106,109,113,117,120,122,125,127],{"class":38,"line":105},8,[36,107,108],{"class":98}," const",[36,110,112],{"class":111},"s_hVV"," host",[36,114,116],{"class":115},"smGrS"," =",[36,118,119],{"class":90}," window",[36,121,22],{"class":94},[36,123,124],{"class":90},"location",[36,126,22],{"class":94},[36,128,129],{"class":90},"hostname\n",[36,131,133,136,140,143,146,150,154,157,160,162,164,166,169,171,174],{"class":38,"line":132},9,[36,134,135],{"class":79}," if",[36,137,139],{"class":138},"skxfh"," (",[36,141,142],{"class":90},"host",[36,144,145],{"class":115}," ===",[36,147,149],{"class":148},"sjJ54"," '",[36,151,153],{"class":152},"s_sjI","localhost",[36,155,156],{"class":148},"'",[36,158,159],{"class":115}," ||",[36,161,112],{"class":90},[36,163,145],{"class":115},[36,165,149],{"class":148},[36,167,168],{"class":152},"127.0.0.1",[36,170,156],{"class":148},[36,172,173],{"class":138},") ",[36,175,176],{"class":79},"return\n",[36,178,180],{"class":38,"line":179},10,[36,181,183],{"emptyLinePlaceholder":182},true,"\n",[36,185,187,190,192],{"class":38,"line":186},11,[36,188,189],{"class":86}," useHead",[36,191,91],{"class":138},[36,193,194],{"class":94},"{\n",[36,196,198,201,204,207,210,213,215,217,220,222,225,228,230,234,237,240],{"class":38,"line":197},12,[36,199,200],{"class":138}," script",[36,202,203],{"class":94},":",[36,205,206],{"class":138}," [",[36,208,209],{"class":94},"{",[36,211,212],{"class":138}," src",[36,214,203],{"class":94},[36,216,149],{"class":148},[36,218,219],{"class":152},"\u002F_vercel\u002Finsights\u002Fscript.js",[36,221,156],{"class":148},[36,223,224],{"class":94},",",[36,226,227],{"class":138}," defer",[36,229,203],{"class":94},[36,231,233],{"class":232},"syTEX"," true",[36,235,236],{"class":94}," }",[36,238,239],{"class":138},"]",[36,241,242],{"class":94},",\n",[36,244,246,248],{"class":38,"line":245},13,[36,247,236],{"class":94},[36,249,250],{"class":138},")\n",[36,252,254,257],{"class":38,"line":253},14,[36,255,256],{"class":94},"}",[36,258,250],{"class":90},[11,260,261],{},"This post is the long version of that comment.",[263,264,266],"h2",{"id":265},"the-peer-dependency-that-didnt-match-anything","The peer dependency that didn't match anything",[11,268,269,270,273,274,277],{},"boiledplate.ai runs on Nuxt 4, and Nuxt 4 depends on vue-router 5. Our lockfile has exactly one copy: ",[15,271,272],{},"vue-router@5.1.0",", hoisted to the top of ",[15,275,276],{},"node_modules",", which is where Nuxt, our pages, and the TypeScript project all expect to resolve it from.",[11,279,280,281,284,285,288],{},"When we added Vercel Web Analytics, we did the documented thing first: ",[15,282,283],{},"npm install @vercel\u002Fanalytics"," and wire up its Nuxt integration. The package's Nuxt build declares a peer dependency of ",[15,286,287],{},"vue-router@^4",". That range predates Nuxt 4 and matches nothing in our tree.",[11,290,291,292,295,296,299],{},"A peer range that can't be satisfied does not produce a clean error. npm resolves it by restructuring the tree, and vue-router stopped being the single hoisted copy our TypeScript setup resolved against. With router types now resolving inconsistently, ",[15,293,294],{},"nuxt typecheck"," failed. The command that our conventions file treats as the definition of done (\"verify with ",[15,297,298],{},"npm run typecheck"," before declaring work done\") was broken by a package that never touches the router in any way we needed.",[11,301,302,303,308],{},"That is the nasty property of stale peer ranges: the package installs fine, the app may even run, and the breakage shows up in tooling output that points nowhere near the package that caused it. We knew the culprit only because the failure appeared in the same session as the install. We have hit this shape of bug before, where ",[304,305,307],"a",{"href":306},"\u002Fblog\u002Fthe-page-that-returned-200-on-the-server-and-500-in-the-browser-a-json-ld-tempor","a page returned 200 on the server and 500 in the browser"," because of an ordering detail three layers down. The cause and the symptom live in different places, and the distance between them is the cost.",[263,310,312],{"id":311},"the-question-that-decided-it-what-does-this-package-do","The question that decided it: what does this package do?",[11,314,315],{},"We had three ways out.",[11,317,318,319,322,323,326,327,330],{},"Option one: ",[15,320,321],{},"overrides"," in ",[15,324,325],{},"package.json",", forcing the nested peer to vue-router 5. Works, but it is a permanent lie in the manifest that someone has to remember exists, and it has to survive every future ",[15,328,329],{},"npm install"," and every dependency audit an agent runs in this repo.",[11,332,333,334,337],{},"Option two: ",[15,335,336],{},"--legacy-peer-deps",". Same idea with less precision, and now every contributor and every CI environment needs the flag.",[11,339,340,341,343,344,346,347,349],{},"Option three: ask what ",[15,342,21],{}," actually does before deciding it is worth an exception. The answer changed the decision. On Vercel, the analytics tracker is not in the npm package. It is a script the platform itself serves at ",[15,345,219],{},", and that script already tracks pageviews and SPA navigations on its own. The npm package is framework glue around inserting that script tag, and the framework glue is precisely the part carrying the stale ",[15,348,287],{}," peer.",[11,351,352],{},"So the dependency was buying us the one part of the product we did not need, at the price of a broken typecheck. We uninstalled it and wrote the plugin above.",[263,354,356],{"id":355},"why-each-line-is-the-way-it-is","Why each line is the way it is",[11,358,359,360,363],{},"The plugin is client-only (the ",[15,361,362],{},".client.ts"," suffix) because there is nothing to do during SSR; the script is a browser tracker.",[11,365,366,369,370,373],{},[15,367,368],{},"useHead"," injects the tag the Nuxt way instead of ",[15,371,372],{},"document.createElement",", so it participates in head management like every other tag in the app.",[11,375,376,377,379,380,382,383,385],{},"The localhost guard is not decoration. Vercel serves ",[15,378,219],{}," only on its own infrastructure. On a local dev server that path does not exist, so without the guard every page load logs a 404 in the console. Skipping ",[15,381,153],{}," and ",[15,384,168],{}," keeps dev output clean, and production needs no opposite check because the script is simply there on Vercel hostnames.",[11,387,388],{},"One constraint outside the code mattered too. This site's privacy policy promises no tracking or analytics cookies, which is the reason boiledplate.ai has no consent banner at all. Vercel Web Analytics is cookie-free, so the bare script keeps that promise exactly as well as the package did. Any analytics product that needed a cookie would have been a privacy-page rewrite plus consent gating, not a dependency swap.",[263,390,392],{"id":391},"when-you-should-keep-the-package","When you should keep the package",[11,394,395,396,398,399,402],{},"This is not \"never use vendor SDKs.\" The honest accounting: ",[15,397,21],{}," gives you a typed ",[15,400,401],{},"track()"," for custom events, a dev debug mode, and maintained framework bindings. If you are sending custom events from a React or Next.js app where the peer ranges are current, the package is the right call.",[11,404,405],{},"Our needs are pageviews and route changes, both of which the platform script handles with zero configuration. The day we want custom events, we will weigh five lines against the script's global interface versus re-importing a dependency that, as of when we hit this, un-hoists our router. Until then, the package's entire value to us was inserting one script tag, and we can insert one script tag.",[263,407,409],{"id":408},"the-habit-worth-taking-away","The habit worth taking away",[11,411,412,413,415,416,419],{},"When ",[15,414,294],{}," (or any type resolution) breaks right after an install, interrogate the tree before the code: ",[15,417,418],{},"npm ls vue-router",", or whichever core package the errors orbit. A duplicated framework-level package is the usual answer, and the dependency that smuggled it in will not be named anywhere in the error output.",[11,421,422],{},"And when the fix is a script tag where a package used to be, write the comment. Eight lines of unusual code with no explanation is a refactor target; eight lines with six lines of \"here is the bug this avoids\" is a decision that survives the next contributor. In a repo built for AI coding agents, that comment is the difference between the fix sticking and an eager agent helpfully reinstalling the problem.",[424,425,426],"style",{},"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 .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 .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 .skxfh, html code.shiki .skxfh{--shiki-light:#E53935;--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 .syTEX, html code.shiki .syTEX{--shiki-light:#FF5370;--shiki-default:#005CC5;--shiki-dark:#79B8FF}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);}",{"title":32,"searchDepth":52,"depth":52,"links":428},[429,430,431,432,433],{"id":265,"depth":46,"text":266},{"id":311,"depth":46,"text":312},{"id":355,"depth":46,"text":356},{"id":391,"depth":46,"text":392},{"id":408,"depth":46,"text":409},"2026-06-10","Installing @vercel\u002Fanalytics broke nuxt typecheck on Nuxt 4: a stale vue-router@^4 peer un-hoisted vue-router. We replaced it with one script tag.","md",{"slug":438},"why-we-dropped-vercelanalytics-for-a-bare-script-tag","\u002Fblog\u002Fvercel-web-analytics-nuxt-4-analytics-package-breaks-typecheck",{"title":5,"description":435},"blog\u002Fvercel-web-analytics-nuxt-4-analytics-package-breaks-typecheck",[443,444,445,446,447],"nuxt","vercel","analytics","vue-router","peer-dependencies","Z3e1AnVOEC6tLXaaEI1G0ci8dpxbz_xdJ4sCJOtFeFY",[450,713,828],{"id":451,"title":452,"author":6,"body":453,"category":6,"date":434,"description":698,"draft":699,"extension":436,"image":6,"meta":700,"navigation":182,"path":702,"seo":703,"stem":704,"tags":705,"__hash__":712},"blog\u002Fblog\u002Ffree-nuxt-supabase-saas-boilerplate.md","A free Nuxt + Supabase SaaS boilerplate (auth, Stripe, email, MIT)",{"type":8,"value":454,"toc":692},[455,458,465,469,472,475,479,482,535,538,545,549,568,628,639,643,650,663,678,689],[11,456,457],{},"Search \"free SaaS boilerplate\" and you get two kinds of result. Abandoned GitHub repos with a login form and a README that stops at \"TODO: payments\", or polished starter kits with a paywall in front of the thing you actually need. We wanted a third option, so we built one and gave it away.",[11,459,460,464],{},[461,462,463],"strong",{},"BoiledPlate Lite"," is a free, MIT-licensed SaaS starter kit. Same app as the paid version — Nuxt 4, Supabase, Stripe, Resend — minus the AI tooling that makes the paid one set itself up. You clone it, wire it up by hand, and ship.",[263,466,468],{"id":467},"what-free-actually-means-here","What \"free\" actually means here",[11,470,471],{},"MIT licensed. Use it for client work, commercial products, whatever — no attribution gymnastics, no \"free for non-commercial.\" The repo is private, but access is free: create an account, tell us your GitHub username, and the invite lands in seconds. The account is the entire price.",[11,473,474],{},"It is also not a toy. The free tier is the real plumbing, not a marketing demo with the hard parts stubbed out. That is the whole point: a SaaS starter that quietly skips webhooks and row-level security is not saving you the weeks that actually hurt.",[263,476,478],{"id":477},"whats-in-the-free-saas-starter","What's in the free SaaS starter",[11,480,481],{},"The stack is the one most people land on anyway — a typed Nuxt + Supabase + Stripe + Resend SaaS template:",[483,484,485,496,511,517,523,529],"ul",{},[486,487,488,491,492,495],"li",{},[461,489,490],{},"Authentication"," — email\u002Fpassword and Google OAuth via Supabase, password reset that does not leak whether an account exists, and route protection that lives in middleware instead of scattered ",[15,493,494],{},"if (!user)"," redirects.",[486,497,498,501,502,505,506,510],{},[461,499,500],{},"Stripe billing"," — Checkout and the billing portal, with a signature-verified, idempotent ",[461,503,504],{},"webhook as the source of truth"," for subscription state. Plan limits are enforced in the database with row-level security, not just hidden in the UI. (This is the part most free boilerplates skip — ",[304,507,509],{"href":508},"\u002Fblog\u002Fstripe-webhooks-source-of-truth","here is why the webhook is the product",".)",[486,512,513,516],{},[461,514,515],{},"Transactional email"," — Resend, with idempotent sends so a Stripe retry can't fire the receipt twice.",[486,518,519,522],{},[461,520,521],{},"A dashboard"," — a real app shell with a collapsible sidebar, a revenue-analytics view built on dependency-free SVG charts, an integrations directory, and a notes CRUD that demonstrates the typed-composable + RLS pattern end to end.",[486,524,525,528],{},[461,526,527],{},"A blog"," — Markdown via Nuxt Content, prerendered and in the sitemap (the one you're reading runs on the same setup).",[486,530,531,534],{},[461,532,533],{},"Internationalization"," — every user-facing string is a locale key, shipped in English, German, Spanish, and French with a language switcher and locale-aware currency and dates. Light\u002Fdark theming out of the box.",[11,536,537],{},"Everything is TypeScript strict with generated Supabase types shared across app and server, so you stop guessing at column names.",[11,539,540,541,22],{},"If you want the long version of why each of those pieces takes longer than it looks, we wrote about ",[304,542,544],{"href":543},"\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate","what it actually takes to wire up Nuxt, Supabase and Stripe",[263,546,548],{"id":547},"quick-start","Quick start",[550,551,552,559,565],"ol",{},[486,553,554,558],{},[304,555,557],{"href":556},"\u002Flogin","Create a free account"," — that's the whole \"checkout\".",[486,560,561,562,564],{},"On your dashboard, the ",[461,563,463],{}," card asks for your GitHub username — the invite to the repo arrives within seconds.",[486,566,567],{},"Accept it, then:",[27,569,573],{"className":570,"code":571,"language":572,"meta":32,"style":32},"language-bash shiki shiki-themes material-theme-lighter github-light github-dark","git clone https:\u002F\u002Fgithub.com\u002Firmscheremilio\u002Fboiledplate-lite-release\ncd boiledplate-lite-release\nnpm install\ncp .env.example .env   # add your Supabase keys (Stripe\u002FResend optional to start)\nnpm run dev\n","bash",[15,574,575,587,596,604,618],{"__ignoreMap":32},[36,576,577,581,584],{"class":38,"line":39},[36,578,580],{"class":579},"sbgvK","git",[36,582,583],{"class":152}," clone",[36,585,586],{"class":152}," https:\u002F\u002Fgithub.com\u002Firmscheremilio\u002Fboiledplate-lite-release\n",[36,588,589,593],{"class":38,"line":46},[36,590,592],{"class":591},"sptTA","cd",[36,594,595],{"class":152}," boiledplate-lite-release\n",[36,597,598,601],{"class":38,"line":52},[36,599,600],{"class":579},"npm",[36,602,603],{"class":152}," install\n",[36,605,606,609,612,615],{"class":38,"line":58},[36,607,608],{"class":579},"cp",[36,610,611],{"class":152}," .env.example",[36,613,614],{"class":152}," .env",[36,616,617],{"class":42},"   # add your Supabase keys (Stripe\u002FResend optional to start)\n",[36,619,620,622,625],{"class":38,"line":64},[36,621,600],{"class":579},[36,623,624],{"class":152}," run",[36,626,627],{"class":152}," dev\n",[11,629,630,631,634,635,638],{},"Apply the migrations in ",[15,632,633],{},"supabase\u002Fmigrations\u002F",", generate types with ",[15,636,637],{},"npm run db:types",", and you have auth, a dashboard, and the data layer running locally. Add Stripe keys when you're ready to turn on billing.",[263,640,642],{"id":641},"where-the-free-version-stops-and-the-paid-one-starts","Where the free version stops, and the paid one starts",[11,644,645,646,649],{},"The free starter gives you the working product. ",[461,647,648],{},"You"," wire it up.",[11,651,652,653,657,658,662],{},"The paid ",[304,654,656],{"href":655},"\u002F","BoiledPlate"," is the same app built to be set up and extended ",[659,660,661],"em",{},"by AI coding agents",". The difference is the build experience, not the feature checklist:",[483,664,665,672,675],{},[486,666,667,668,671],{},"An ",[15,669,670],{},"AGENTS.md"," conventions contract plus vendored skills, so Claude Code, Cursor, or Codex build on-pattern instead of inventing a second way to do everything.",[486,673,674],{},"A scripted setup interview that brands and configures the whole codebase to your answers, and one-command provisioning of Supabase and Stripe.",[486,676,677],{},"Semantic updates you apply to your customized code with one sentence, instead of a scary upstream merge.",[11,679,680,681,684,685,22],{},"If you just want a free, honest, production-shaped SaaS starter kit, the Lite version is genuinely all you need. If you'd rather an agent did the wiring — and kept the starter updatable as your product grows — ",[304,682,683],{"href":655},"that's what the full version is for",". Either way, you start from the same plumbing we trust enough to run this site on. ",[304,686,688],{"href":687},"\u002Fblog\u002Fhow-to-ship-saas-fast","Here's how we think about shipping SaaS fast",[424,690,691],{},"html pre.shiki code .sbgvK, html code.shiki .sbgvK{--shiki-light:#E2931D;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s_sjI, html code.shiki .s_sjI{--shiki-light:#91B859;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sptTA, html code.shiki .sptTA{--shiki-light:#6182B8;--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 .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);}",{"title":32,"searchDepth":52,"depth":52,"links":693},[694,695,696,697],{"id":467,"depth":46,"text":468},{"id":477,"depth":46,"text":478},{"id":547,"depth":46,"text":548},{"id":641,"depth":46,"text":642},"BoiledPlate Lite is a free, MIT-licensed SaaS starter kit: Supabase auth, Stripe billing, transactional email, a blog, and i18n on Nuxt 4. Clone it and ship.",false,{"slug":701},"free-nuxt-supabase-saas-boilerplate","\u002Fblog\u002Ffree-nuxt-supabase-saas-boilerplate",{"title":452,"description":698},"blog\u002Ffree-nuxt-supabase-saas-boilerplate",[706,707,443,708,709,710,711],"free-saas-boilerplate","saas-starter-kit","supabase","stripe","open-source","mit","BDvwkRrDE4x6wJ3Yb367mmxDGZ0VG2D3jrS-3-RR34U",{"id":714,"title":715,"author":6,"body":716,"category":6,"date":819,"description":820,"draft":699,"extension":436,"image":6,"meta":821,"navigation":182,"path":687,"seo":823,"stem":824,"tags":825,"__hash__":827},"blog\u002Fblog\u002Fhow-to-ship-saas-fast.md","How to ship SaaS fast: the stack is easy, the plumbing is the project",{"type":8,"value":717,"toc":813},[718,721,724,728,735,738,742,745,759,769,775,781,787,791,794,797,801,804,810],[11,719,720],{},"Ask ten founders how to ship SaaS fast and you get ten stack arguments. Next vs Nuxt, Postgres vs a hosted DB, Stripe vs Paddle. Pick whatever you already know and the stack debate is over in an afternoon. The framework is not what slows you down.",[11,722,723],{},"What slows you down is the plumbing between the pieces. We know, because we built BoiledPlate by writing that plumbing and then keeping a running log of every edge case it took to make it trustworthy. This site runs on it. Here is the stack we landed on, and then the part nobody warns you about.",[263,725,727],{"id":726},"the-stack-settled-in-one-paragraph","The stack, settled in one paragraph",[11,729,730,731,734],{},"Nuxt 4 with TypeScript in strict mode for the app. Supabase for Postgres, auth, and row-level security. Stripe for payments, with webhooks as the source of truth. Resend for transactional email. Nuxt UI v4 on Tailwind for the interface, and ",[15,732,733],{},"@nuxtjs\u002Fi18n"," so the product speaks more than one language from day one. None of these choices are clever. They are the boring, well-documented options, and that is the point: every one of them has a paved path and an agent can stay on it.",[11,736,737],{},"You can assemble that list in a day. Then you discover the list was the easy 10 percent.",[263,739,741],{"id":740},"the-90-percent-plumbing-you-only-notice-when-it-breaks","The 90 percent: plumbing you only notice when it breaks",[11,743,744],{},"A checkout button that charges a card is a demo. A billing system you can trust at 3am is a project. The gap between them is a pile of edge cases that each look like a one-liner until you actually hit them. Here are the ones that shaped our own commit history.",[11,746,747,750,751,754,755,758],{},[461,748,749],{},"Webhook idempotency."," Stripe retries deliveries. If your handler is not idempotent, a retry double-records a purchase or double-sends a confirmation email. Our handler claims the event id in a ",[15,752,753],{},"stripe_events"," table before doing any work and releases it on failure, so a retried event is recognized and skipped, but a genuinely failed one still gets reprocessed. That single rule (writes are driven by signature-verified, idempotent webhooks and nothing else) is what makes the billing state believable. We wrote a ",[304,756,757],{"href":508},"whole post on why webhooks are the source of truth"," because it is the easiest thing to get subtly wrong.",[11,760,761,764,765,768],{},[461,762,763],{},"Refunds, not just charges."," Most tutorials stop at the happy path. A real product has to handle a chargeback. Our ",[15,766,767],{},"charge.refunded"," case revokes the site entitlement, and because we deliver the product as a private GitHub repo invite, it also removes the buyer from that repo and cancels any still-pending invitation. Full refunds only, partial refunds keep access, and the revocation re-runs safely on Stripe retries even if the refund timestamp was already stamped. That is one event type, and it is four behaviors that all have to agree.",[11,770,771,774],{},[461,772,773],{},"RLS in the same migration, every time."," It is tempting to ship a table now and add policies \"later.\" Later is how data leaks. Our rule is that every table turns on row-level security with explicit policies in the same migration that creates it. No table ever exists in a state where the wrong user can read it.",[11,776,777,780],{},[461,778,779],{},"The legal plumbing."," Selling digital goods to EU buyers means the 14-day right of withdrawal disappears the moment you grant immediate access, but only if the buyer expressly waives it. We collect that waiver as a required consent in Stripe Checkout, with the acceptance message localized, because the terms page depends on it. That is not a feature anyone demos. It is the difference between a sale you can keep and a refund you have to give back.",[11,782,783,786],{},[461,784,785],{},"Ordering."," Inside a paid checkout the steps run in a fixed order: record the purchase, deliver the product, then send the email. Get the order wrong and a delivery retry double-sends the receipt. None of this is hard once you know it. Knowing it is what costs the weeks.",[263,788,790],{"id":789},"why-this-is-the-real-ship-fast-lever","Why this is the real \"ship fast\" lever",[11,792,793],{},"Add those up. Webhook idempotency, refund revocation across two systems, RLS on every table, EU consent collection, delivery state machines, password reset, auth email over SMTP, a sitemap, JSON-LD that does not crash on hydration. Each is a few hours of reading docs and a few more hours of getting the edge case right. Together they are the reason a \"weekend SaaS\" takes two months.",[11,795,796],{},"So the honest answer to \"how do I ship SaaS fast\" is not a framework. It is: do not write the plumbing again. That is the entire thesis behind BoiledPlate. The stack above comes pre-wired, the webhook is already idempotent, the refund path already revokes access, the tables already have their policies, and the EU waiver is already in the checkout. You start from the part that is actually your product.",[263,798,800],{"id":799},"the-setup-is-plumbing-too","The setup is plumbing too",[11,802,803],{},"There is one more place the days go: wiring the accounts. Creating the Stripe product and price, registering the webhook endpoint with the right event types, linking the Supabase project, pushing migrations, configuring the Google provider and the Resend SMTP. We automated that. Point a coding agent at the repo and its first session interviews you (name, languages, theme, billing model), reshapes the codebase to your answers, and then provisions Stripe and Supabase by running the setup scripts instead of clicking through dashboards. The conventions are written down so the agent stays on-pattern while it does it.",[11,805,806,807,22],{},"The result is that the boring, error-prone, well-understood 90 percent is already done and verified, which is exactly the 90 percent that was standing between you and a launch. If you want the longer tour of how the three services fit together, we wrote that up in ",[304,808,809],{"href":543},"the Nuxt + Supabase + Stripe boilerplate post",[11,811,812],{},"Ship fast by spending your time on the 10 percent that is yours. Let the plumbing be plumbing.",{"title":32,"searchDepth":52,"depth":52,"links":814},[815,816,817,818],{"id":726,"depth":46,"text":727},{"id":740,"depth":46,"text":741},{"id":789,"depth":46,"text":790},{"id":799,"depth":46,"text":800},"2026-06-09","The framework picks itself. Webhook idempotency, RLS, refunds, EU consent: the plumbing is what eats your launch. The anti-plumbing SaaS stack.",{"slug":822},"how-to-ship-saas-fast",{"title":715,"description":820},"blog\u002Fhow-to-ship-saas-fast",[826,443,708,709],"saas","VdQKZEYdVWgs4M9SWVxC0T-_oVqEPor_ynaRVdnSTpY",{"id":829,"title":830,"author":6,"body":831,"category":6,"date":819,"description":903,"draft":699,"extension":436,"image":6,"meta":904,"navigation":182,"path":543,"seo":906,"stem":907,"tags":908,"__hash__":911},"blog\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate.md","What it actually takes to wire up a Nuxt, Supabase and Stripe SaaS",{"type":8,"value":832,"toc":896},[833,836,839,842,846,849,852,856,859,866,870,873,876,880,883,887,890,893],[11,834,835],{},"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,837,838],{},"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,840,841],{},"Here is what each piece actually asks of you, roughly in the order it tends to bite.",[263,843,845],{"id":844},"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,847,848],{},"Getting a login form working with Supabase takes an afternoon. Email and password, maybe Google OAuth, a session, a redirect. Feels done.",[11,850,851],{},"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.",[263,853,855],{"id":854},"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,857,858],{},"Everyone wires the checkout first because it is satisfying. Money goes in, you get redirected, you feel like a founder.",[11,860,861,862,865],{},"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 ",[304,863,864],{"href":508},"the webhook trap in more detail here"," if you want the longer version.",[263,867,869],{"id":868},"resend-simple-to-send-annoying-to-send-exactly-once","Resend: simple to send, annoying to send exactly once",[11,871,872],{},"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,874,875],{},"Which brings up the thing that quietly doubles the work on everything above.",[263,877,879],{"id":878},"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,881,882],{},"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.",[263,884,886],{"id":885},"so-how-long-is-all-of-that","So how long is all of that",[11,888,889],{},"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,891,892],{},"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,894,895],{},"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":32,"searchDepth":52,"depth":52,"links":897},[898,899,900,901,902],{"id":844,"depth":46,"text":845},{"id":854,"depth":46,"text":855},{"id":868,"depth":46,"text":869},{"id":878,"depth":46,"text":879},{"id":885,"depth":46,"text":886},"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":905},"nuxt-supabase-stripe-saas-boilerplate",{"title":830,"description":903},"blog\u002Fnuxt-supabase-stripe-saas-boilerplate",[443,708,709,909,826,910],"resend","boilerplate","O9aBZHsolIkxENJU_ebdFE9p0IW2dowSQQ-IXutuW_M",1781125992164]