[{"data":1,"prerenderedAt":18873},["ShallowReactive",2],{"blog-paginated-count":3,"blog-paginated-10":4,"blog-paginated-cats":18227},640,[5,2403,3894,4399,7680,9430,11375,11636,12452,12700,13412,15036,15325,16379,17064],{"id":6,"title":7,"author":8,"body":11,"category":2385,"date":2386,"description":2387,"extension":2388,"featured":2389,"image":2390,"keywords":2391,"meta":2394,"navigation":129,"path":2395,"readTime":165,"seo":2396,"stem":2397,"tags":2398,"__hash__":2402},"blog/blog/nuxt-pwa-guide.md","Building a PWA With Nuxt: Offline Support and App-Like Features",{"name":9,"bio":10},"James Ross Jr.","Strategic Systems Architect & Enterprise Software Developer",{"type":12,"value":13,"toc":2374},"minimark",[14,18,21,26,29,36,42,48,51,55,63,92,95,563,567,570,576,582,588,591,915,919,922,1099,1277,1280,1284,1287,1610,1614,1617,1958,1961,1965,1968,1984,1987,2126,2129,2296,2300,2303,2322,2325,2328,2338,2340,2344,2370],[15,16,17],"p",{},"Progressive Web Apps occupy an interesting position. They are not as capable as native apps, but they are dramatically more capable than regular websites — and the installation and distribution story is far simpler than app stores. For the right use cases, a PWA is the best of both worlds.",[15,19,20],{},"I have built production PWAs with Nuxt for clients who needed mobile-app-like experiences without the overhead of maintaining separate iOS and Android codebases. The tooling has matured enough that the implementation is no longer painful — but there are still decisions to make carefully.",[22,23,25],"h2",{"id":24},"what-makes-a-pwa","What Makes a PWA",[15,27,28],{},"A Progressive Web App must satisfy three criteria:",[15,30,31,35],{},[32,33,34],"strong",{},"Served over HTTPS."," Security requirement for service workers. Every hosting platform worth using enables this by default.",[15,37,38,41],{},[32,39,40],{},"A web app manifest."," A JSON file that tells browsers how to present the app when installed: name, icon, colors, display mode.",[15,43,44,47],{},[32,45,46],{},"A service worker."," A JavaScript worker that intercepts network requests, enables offline support, and handles background sync and push notifications.",[15,49,50],{},"That is the technical minimum. In practice, a good PWA also has fast performance (Lighthouse PWA audit should pass), responsive design that works on mobile, and icons at multiple sizes.",[22,52,54],{"id":53},"setting-up-vite-pwanuxt","Setting Up @vite-pwa/nuxt",[15,56,57,58,62],{},"The ",[59,60,61],"code",{},"@vite-pwa/nuxt"," module (a Nuxt adapter for Vite PWA plugin) handles service worker generation and manifest configuration:",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-bash shiki shiki-themes github-dark","npm install --save-dev @vite-pwa/nuxt\n","bash","",[59,71,72],{"__ignoreMap":69},[73,74,77,81,85,89],"span",{"class":75,"line":76},"line",1,[73,78,80],{"class":79},"svObZ","npm",[73,82,84],{"class":83},"sU2Wk"," install",[73,86,88],{"class":87},"sDLfK"," --save-dev",[73,90,91],{"class":83}," @vite-pwa/nuxt\n",[15,93,94],{},"Add it to your Nuxt config:",[64,96,100],{"className":97,"code":98,"language":99,"meta":69,"style":69},"language-typescript shiki shiki-themes github-dark","// nuxt.config.ts\nmodules: ['@vite-pwa/nuxt'],\n\nPwa: {\n registerType: 'autoUpdate',\n manifest: {\n name: 'My Application',\n short_name: 'MyApp',\n description: 'My app description',\n theme_color: '#2563eb',\n background_color: '#ffffff',\n display: 'standalone',\n orientation: 'portrait',\n icons: [\n {\n src: '/icons/icon-192x192.png',\n sizes: '192x192',\n type: 'image/png',\n },\n {\n src: '/icons/icon-512x512.png',\n sizes: '512x512',\n type: 'image/png',\n },\n {\n src: '/icons/icon-512x512.png',\n sizes: '512x512',\n type: 'image/png',\n purpose: 'maskable',\n },\n ],\n },\n workbox: {\n navigateFallback: '/',\n cleanupOutdatedCaches: true,\n globPatterns: ['**/*.{js,css,html,png,svg,ico,txt}'],\n },\n client: {\n installPrompt: true,\n periodicSyncForUpdates: 3600,\n },\n devOptions: {\n enabled: true,\n suppressWarnings: true,\n navigateFallback: '/',\n type: 'module',\n },\n},\n","typescript",[59,101,102,108,124,131,140,155,163,176,189,202,215,228,241,254,263,269,280,291,302,308,313,323,333,342,347,352,361,370,379,390,395,401,406,414,427,440,453,458,466,478,491,496,504,516,528,539,552,557],{"__ignoreMap":69},[73,103,104],{"class":75,"line":76},[73,105,107],{"class":106},"sAwPA","// nuxt.config.ts\n",[73,109,111,114,118,121],{"class":75,"line":110},2,[73,112,113],{"class":79},"modules",[73,115,117],{"class":116},"s95oV",": [",[73,119,120],{"class":83},"'@vite-pwa/nuxt'",[73,122,123],{"class":116},"],\n",[73,125,127],{"class":75,"line":126},3,[73,128,130],{"emptyLinePlaceholder":129},true,"\n",[73,132,134,137],{"class":75,"line":133},4,[73,135,136],{"class":79},"Pwa",[73,138,139],{"class":116},": {\n",[73,141,143,146,149,152],{"class":75,"line":142},5,[73,144,145],{"class":79}," registerType",[73,147,148],{"class":116},": ",[73,150,151],{"class":83},"'autoUpdate'",[73,153,154],{"class":116},",\n",[73,156,158,161],{"class":75,"line":157},6,[73,159,160],{"class":79}," manifest",[73,162,139],{"class":116},[73,164,166,169,171,174],{"class":75,"line":165},7,[73,167,168],{"class":79}," name",[73,170,148],{"class":116},[73,172,173],{"class":83},"'My Application'",[73,175,154],{"class":116},[73,177,179,182,184,187],{"class":75,"line":178},8,[73,180,181],{"class":79}," short_name",[73,183,148],{"class":116},[73,185,186],{"class":83},"'MyApp'",[73,188,154],{"class":116},[73,190,192,195,197,200],{"class":75,"line":191},9,[73,193,194],{"class":79}," description",[73,196,148],{"class":116},[73,198,199],{"class":83},"'My app description'",[73,201,154],{"class":116},[73,203,205,208,210,213],{"class":75,"line":204},10,[73,206,207],{"class":79}," theme_color",[73,209,148],{"class":116},[73,211,212],{"class":83},"'#2563eb'",[73,214,154],{"class":116},[73,216,218,221,223,226],{"class":75,"line":217},11,[73,219,220],{"class":79}," background_color",[73,222,148],{"class":116},[73,224,225],{"class":83},"'#ffffff'",[73,227,154],{"class":116},[73,229,231,234,236,239],{"class":75,"line":230},12,[73,232,233],{"class":79}," display",[73,235,148],{"class":116},[73,237,238],{"class":83},"'standalone'",[73,240,154],{"class":116},[73,242,244,247,249,252],{"class":75,"line":243},13,[73,245,246],{"class":79}," orientation",[73,248,148],{"class":116},[73,250,251],{"class":83},"'portrait'",[73,253,154],{"class":116},[73,255,257,260],{"class":75,"line":256},14,[73,258,259],{"class":79}," icons",[73,261,262],{"class":116},": [\n",[73,264,266],{"class":75,"line":265},15,[73,267,268],{"class":116}," {\n",[73,270,272,275,278],{"class":75,"line":271},16,[73,273,274],{"class":116}," src: ",[73,276,277],{"class":83},"'/icons/icon-192x192.png'",[73,279,154],{"class":116},[73,281,283,286,289],{"class":75,"line":282},17,[73,284,285],{"class":116}," sizes: ",[73,287,288],{"class":83},"'192x192'",[73,290,154],{"class":116},[73,292,294,297,300],{"class":75,"line":293},18,[73,295,296],{"class":116}," type: ",[73,298,299],{"class":83},"'image/png'",[73,301,154],{"class":116},[73,303,305],{"class":75,"line":304},19,[73,306,307],{"class":116}," },\n",[73,309,311],{"class":75,"line":310},20,[73,312,268],{"class":116},[73,314,316,318,321],{"class":75,"line":315},21,[73,317,274],{"class":116},[73,319,320],{"class":83},"'/icons/icon-512x512.png'",[73,322,154],{"class":116},[73,324,326,328,331],{"class":75,"line":325},22,[73,327,285],{"class":116},[73,329,330],{"class":83},"'512x512'",[73,332,154],{"class":116},[73,334,336,338,340],{"class":75,"line":335},23,[73,337,296],{"class":116},[73,339,299],{"class":83},[73,341,154],{"class":116},[73,343,345],{"class":75,"line":344},24,[73,346,307],{"class":116},[73,348,350],{"class":75,"line":349},25,[73,351,268],{"class":116},[73,353,355,357,359],{"class":75,"line":354},26,[73,356,274],{"class":116},[73,358,320],{"class":83},[73,360,154],{"class":116},[73,362,364,366,368],{"class":75,"line":363},27,[73,365,285],{"class":116},[73,367,330],{"class":83},[73,369,154],{"class":116},[73,371,373,375,377],{"class":75,"line":372},28,[73,374,296],{"class":116},[73,376,299],{"class":83},[73,378,154],{"class":116},[73,380,382,385,388],{"class":75,"line":381},29,[73,383,384],{"class":116}," purpose: ",[73,386,387],{"class":83},"'maskable'",[73,389,154],{"class":116},[73,391,393],{"class":75,"line":392},30,[73,394,307],{"class":116},[73,396,398],{"class":75,"line":397},31,[73,399,400],{"class":116}," ],\n",[73,402,404],{"class":75,"line":403},32,[73,405,307],{"class":116},[73,407,409,412],{"class":75,"line":408},33,[73,410,411],{"class":79}," workbox",[73,413,139],{"class":116},[73,415,417,420,422,425],{"class":75,"line":416},34,[73,418,419],{"class":79}," navigateFallback",[73,421,148],{"class":116},[73,423,424],{"class":83},"'/'",[73,426,154],{"class":116},[73,428,430,433,435,438],{"class":75,"line":429},35,[73,431,432],{"class":79}," cleanupOutdatedCaches",[73,434,148],{"class":116},[73,436,437],{"class":87},"true",[73,439,154],{"class":116},[73,441,443,446,448,451],{"class":75,"line":442},36,[73,444,445],{"class":79}," globPatterns",[73,447,117],{"class":116},[73,449,450],{"class":83},"'**/*.{js,css,html,png,svg,ico,txt}'",[73,452,123],{"class":116},[73,454,456],{"class":75,"line":455},37,[73,457,307],{"class":116},[73,459,461,464],{"class":75,"line":460},38,[73,462,463],{"class":79}," client",[73,465,139],{"class":116},[73,467,469,472,474,476],{"class":75,"line":468},39,[73,470,471],{"class":79}," installPrompt",[73,473,148],{"class":116},[73,475,437],{"class":87},[73,477,154],{"class":116},[73,479,481,484,486,489],{"class":75,"line":480},40,[73,482,483],{"class":79}," periodicSyncForUpdates",[73,485,148],{"class":116},[73,487,488],{"class":87},"3600",[73,490,154],{"class":116},[73,492,494],{"class":75,"line":493},41,[73,495,307],{"class":116},[73,497,499,502],{"class":75,"line":498},42,[73,500,501],{"class":79}," devOptions",[73,503,139],{"class":116},[73,505,507,510,512,514],{"class":75,"line":506},43,[73,508,509],{"class":79}," enabled",[73,511,148],{"class":116},[73,513,437],{"class":87},[73,515,154],{"class":116},[73,517,519,522,524,526],{"class":75,"line":518},44,[73,520,521],{"class":79}," suppressWarnings",[73,523,148],{"class":116},[73,525,437],{"class":87},[73,527,154],{"class":116},[73,529,531,533,535,537],{"class":75,"line":530},45,[73,532,419],{"class":79},[73,534,148],{"class":116},[73,536,424],{"class":83},[73,538,154],{"class":116},[73,540,542,545,547,550],{"class":75,"line":541},46,[73,543,544],{"class":79}," type",[73,546,148],{"class":116},[73,548,549],{"class":83},"'module'",[73,551,154],{"class":116},[73,553,555],{"class":75,"line":554},47,[73,556,307],{"class":116},[73,558,560],{"class":75,"line":559},48,[73,561,562],{"class":116},"},\n",[22,564,566],{"id":565},"caching-strategies","Caching Strategies",[15,568,569],{},"The most important part of your service worker configuration is the caching strategy. Different content types need different strategies.",[15,571,572,575],{},[32,573,574],{},"Cache First"," for static assets (JS, CSS, fonts, images): serve from cache immediately, refresh in background. The best strategy for assets that change only on deployment.",[15,577,578,581],{},[32,579,580],{},"Network First"," for dynamic content (API responses, user data): try the network first, fall back to cache on failure. Ensures freshness while providing offline fallback.",[15,583,584,587],{},[32,585,586],{},"Stale While Revalidate"," for content that can be slightly stale: serve cache immediately, refresh in background. Best for content where a slightly outdated version is acceptable.",[15,589,590],{},"Configure runtime caching in your workbox options:",[64,592,594],{"className":97,"code":593,"language":99,"meta":69,"style":69},"workbox: {\n runtimeCaching: [\n {\n urlPattern: ({ request }) => request.destination === 'image',\n handler: 'CacheFirst',\n options: {\n cacheName: 'images-cache',\n expiration: {\n maxEntries: 100,\n maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days\n },\n },\n },\n {\n urlPattern: /^https:\\/\\/api\\.yourdomain\\.com\\//,\n handler: 'NetworkFirst',\n options: {\n cacheName: 'api-cache',\n expiration: {\n maxEntries: 50,\n maxAgeSeconds: 60 * 60, // 1 hour fallback\n },\n networkTimeoutSeconds: 5,\n },\n },\n {\n urlPattern: /^https:\\/\\/fonts\\.googleapis\\.com\\//,\n handler: 'StaleWhileRevalidate',\n options: {\n cacheName: 'google-fonts-stylesheets',\n },\n },\n ],\n},\n",[59,595,596,603,610,614,644,654,659,669,674,684,714,718,722,726,730,771,780,784,793,797,806,821,825,835,839,843,847,877,886,890,899,903,907,911],{"__ignoreMap":69},[73,597,598,601],{"class":75,"line":76},[73,599,600],{"class":79},"workbox",[73,602,139],{"class":116},[73,604,605,608],{"class":75,"line":110},[73,606,607],{"class":79}," runtimeCaching",[73,609,262],{"class":116},[73,611,612],{"class":75,"line":126},[73,613,268],{"class":116},[73,615,616,619,622,626,629,633,636,639,642],{"class":75,"line":133},[73,617,618],{"class":79}," urlPattern",[73,620,621],{"class":116},": ({ ",[73,623,625],{"class":624},"s9osk","request",[73,627,628],{"class":116}," }) ",[73,630,632],{"class":631},"snl16","=>",[73,634,635],{"class":116}," request.destination ",[73,637,638],{"class":631},"===",[73,640,641],{"class":83}," 'image'",[73,643,154],{"class":116},[73,645,646,649,652],{"class":75,"line":142},[73,647,648],{"class":116}," handler: ",[73,650,651],{"class":83},"'CacheFirst'",[73,653,154],{"class":116},[73,655,656],{"class":75,"line":157},[73,657,658],{"class":116}," options: {\n",[73,660,661,664,667],{"class":75,"line":165},[73,662,663],{"class":116}," cacheName: ",[73,665,666],{"class":83},"'images-cache'",[73,668,154],{"class":116},[73,670,671],{"class":75,"line":178},[73,672,673],{"class":116}," expiration: {\n",[73,675,676,679,682],{"class":75,"line":191},[73,677,678],{"class":116}," maxEntries: ",[73,680,681],{"class":87},"100",[73,683,154],{"class":116},[73,685,686,689,692,695,698,700,703,705,708,711],{"class":75,"line":204},[73,687,688],{"class":116}," maxAgeSeconds: ",[73,690,691],{"class":87},"60",[73,693,694],{"class":631}," *",[73,696,697],{"class":87}," 60",[73,699,694],{"class":631},[73,701,702],{"class":87}," 24",[73,704,694],{"class":631},[73,706,707],{"class":87}," 30",[73,709,710],{"class":116},", ",[73,712,713],{"class":106},"// 30 days\n",[73,715,716],{"class":75,"line":217},[73,717,307],{"class":116},[73,719,720],{"class":75,"line":230},[73,721,307],{"class":116},[73,723,724],{"class":75,"line":243},[73,725,307],{"class":116},[73,727,728],{"class":75,"line":256},[73,729,268],{"class":116},[73,731,732,735,738,741,745,749,752,755,758,760,763,766,769],{"class":75,"line":265},[73,733,734],{"class":116}," urlPattern:",[73,736,737],{"class":83}," /",[73,739,740],{"class":631},"^",[73,742,744],{"class":743},"sns5M","https:",[73,746,748],{"class":747},"sRjNt","\\/\\/",[73,750,751],{"class":743},"api",[73,753,754],{"class":747},"\\.",[73,756,757],{"class":743},"yourdomain",[73,759,754],{"class":747},[73,761,762],{"class":743},"com",[73,764,765],{"class":747},"\\/",[73,767,768],{"class":83},"/",[73,770,154],{"class":116},[73,772,773,775,778],{"class":75,"line":271},[73,774,648],{"class":116},[73,776,777],{"class":83},"'NetworkFirst'",[73,779,154],{"class":116},[73,781,782],{"class":75,"line":282},[73,783,658],{"class":116},[73,785,786,788,791],{"class":75,"line":293},[73,787,663],{"class":116},[73,789,790],{"class":83},"'api-cache'",[73,792,154],{"class":116},[73,794,795],{"class":75,"line":304},[73,796,673],{"class":116},[73,798,799,801,804],{"class":75,"line":310},[73,800,678],{"class":116},[73,802,803],{"class":87},"50",[73,805,154],{"class":116},[73,807,808,810,812,814,816,818],{"class":75,"line":315},[73,809,688],{"class":116},[73,811,691],{"class":87},[73,813,694],{"class":631},[73,815,697],{"class":87},[73,817,710],{"class":116},[73,819,820],{"class":106},"// 1 hour fallback\n",[73,822,823],{"class":75,"line":325},[73,824,307],{"class":116},[73,826,827,830,833],{"class":75,"line":335},[73,828,829],{"class":116}," networkTimeoutSeconds: ",[73,831,832],{"class":87},"5",[73,834,154],{"class":116},[73,836,837],{"class":75,"line":344},[73,838,307],{"class":116},[73,840,841],{"class":75,"line":349},[73,842,307],{"class":116},[73,844,845],{"class":75,"line":354},[73,846,268],{"class":116},[73,848,849,851,853,855,857,859,862,864,867,869,871,873,875],{"class":75,"line":363},[73,850,734],{"class":116},[73,852,737],{"class":83},[73,854,740],{"class":631},[73,856,744],{"class":743},[73,858,748],{"class":747},[73,860,861],{"class":743},"fonts",[73,863,754],{"class":747},[73,865,866],{"class":743},"googleapis",[73,868,754],{"class":747},[73,870,762],{"class":743},[73,872,765],{"class":747},[73,874,768],{"class":83},[73,876,154],{"class":116},[73,878,879,881,884],{"class":75,"line":372},[73,880,648],{"class":116},[73,882,883],{"class":83},"'StaleWhileRevalidate'",[73,885,154],{"class":116},[73,887,888],{"class":75,"line":381},[73,889,658],{"class":116},[73,891,892,894,897],{"class":75,"line":392},[73,893,663],{"class":116},[73,895,896],{"class":83},"'google-fonts-stylesheets'",[73,898,154],{"class":116},[73,900,901],{"class":75,"line":397},[73,902,307],{"class":116},[73,904,905],{"class":75,"line":403},[73,906,307],{"class":116},[73,908,909],{"class":75,"line":408},[73,910,400],{"class":116},[73,912,913],{"class":75,"line":416},[73,914,562],{"class":116},[22,916,918],{"id":917},"offline-ui","Offline UI",[15,920,921],{},"Detecting offline state and showing appropriate UI is essential for a good PWA experience. Users need to know when they are offline and understand what functionality is limited:",[64,923,925],{"className":97,"code":924,"language":99,"meta":69,"style":69},"// composables/useNetwork.ts\nexport function useNetwork() {\n const isOnline = ref(navigator.onLine)\n\n window.addEventListener('online', () => { isOnline.value = true })\n window.addEventListener('offline', () => { isOnline.value = false })\n\n onUnmounted(() => {\n window.removeEventListener('online', () => {})\n window.removeEventListener('offline', () => {})\n })\n\n return { isOnline: readonly(isOnline) }\n}\n",[59,926,927,932,946,963,967,998,1022,1026,1038,1056,1072,1076,1080,1094],{"__ignoreMap":69},[73,928,929],{"class":75,"line":76},[73,930,931],{"class":106},"// composables/useNetwork.ts\n",[73,933,934,937,940,943],{"class":75,"line":110},[73,935,936],{"class":631},"export",[73,938,939],{"class":631}," function",[73,941,942],{"class":79}," useNetwork",[73,944,945],{"class":116},"() {\n",[73,947,948,951,954,957,960],{"class":75,"line":126},[73,949,950],{"class":631}," const",[73,952,953],{"class":87}," isOnline",[73,955,956],{"class":631}," =",[73,958,959],{"class":79}," ref",[73,961,962],{"class":116},"(navigator.onLine)\n",[73,964,965],{"class":75,"line":133},[73,966,130],{"emptyLinePlaceholder":129},[73,968,969,972,975,978,981,984,986,989,992,995],{"class":75,"line":142},[73,970,971],{"class":116}," window.",[73,973,974],{"class":79},"addEventListener",[73,976,977],{"class":116},"(",[73,979,980],{"class":83},"'online'",[73,982,983],{"class":116},", () ",[73,985,632],{"class":631},[73,987,988],{"class":116}," { isOnline.value ",[73,990,991],{"class":631},"=",[73,993,994],{"class":87}," true",[73,996,997],{"class":116}," })\n",[73,999,1000,1002,1004,1006,1009,1011,1013,1015,1017,1020],{"class":75,"line":157},[73,1001,971],{"class":116},[73,1003,974],{"class":79},[73,1005,977],{"class":116},[73,1007,1008],{"class":83},"'offline'",[73,1010,983],{"class":116},[73,1012,632],{"class":631},[73,1014,988],{"class":116},[73,1016,991],{"class":631},[73,1018,1019],{"class":87}," false",[73,1021,997],{"class":116},[73,1023,1024],{"class":75,"line":165},[73,1025,130],{"emptyLinePlaceholder":129},[73,1027,1028,1031,1034,1036],{"class":75,"line":178},[73,1029,1030],{"class":79}," onUnmounted",[73,1032,1033],{"class":116},"(() ",[73,1035,632],{"class":631},[73,1037,268],{"class":116},[73,1039,1040,1042,1045,1047,1049,1051,1053],{"class":75,"line":191},[73,1041,971],{"class":116},[73,1043,1044],{"class":79},"removeEventListener",[73,1046,977],{"class":116},[73,1048,980],{"class":83},[73,1050,983],{"class":116},[73,1052,632],{"class":631},[73,1054,1055],{"class":116}," {})\n",[73,1057,1058,1060,1062,1064,1066,1068,1070],{"class":75,"line":204},[73,1059,971],{"class":116},[73,1061,1044],{"class":79},[73,1063,977],{"class":116},[73,1065,1008],{"class":83},[73,1067,983],{"class":116},[73,1069,632],{"class":631},[73,1071,1055],{"class":116},[73,1073,1074],{"class":75,"line":217},[73,1075,997],{"class":116},[73,1077,1078],{"class":75,"line":230},[73,1079,130],{"emptyLinePlaceholder":129},[73,1081,1082,1085,1088,1091],{"class":75,"line":243},[73,1083,1084],{"class":631}," return",[73,1086,1087],{"class":116}," { isOnline: ",[73,1089,1090],{"class":79},"readonly",[73,1092,1093],{"class":116},"(isOnline) }\n",[73,1095,1096],{"class":75,"line":256},[73,1097,1098],{"class":116},"}\n",[64,1100,1104],{"className":1101,"code":1102,"language":1103,"meta":69,"style":69},"language-vue shiki shiki-themes github-dark","\u003C!-- components/OfflineBanner.client.vue -->\n\u003Cscript setup lang=\"ts\">\nconst { isOnline } = useNetwork()\n\u003C/script>\n\n\u003Ctemplate>\n \u003CTransition name=\"slide-down\">\n \u003Cdiv\n v-if=\"!isOnline\"\n class=\"fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white text-center py-2 text-sm font-medium\"\n role=\"alert\"\n aria-live=\"polite\"\n >\n You are offline. Some features may not be available.\n \u003C/div>\n \u003C/Transition>\n\u003C/template>\n","vue",[59,1105,1106,1111,1134,1155,1164,1168,1177,1194,1201,1211,1221,1231,1241,1246,1251,1261,1269],{"__ignoreMap":69},[73,1107,1108],{"class":75,"line":76},[73,1109,1110],{"class":106},"\u003C!-- components/OfflineBanner.client.vue -->\n",[73,1112,1113,1116,1120,1123,1126,1128,1131],{"class":75,"line":110},[73,1114,1115],{"class":116},"\u003C",[73,1117,1119],{"class":1118},"s4JwU","script",[73,1121,1122],{"class":79}," setup",[73,1124,1125],{"class":79}," lang",[73,1127,991],{"class":116},[73,1129,1130],{"class":83},"\"ts\"",[73,1132,1133],{"class":116},">\n",[73,1135,1136,1139,1142,1145,1148,1150,1152],{"class":75,"line":126},[73,1137,1138],{"class":631},"const",[73,1140,1141],{"class":116}," { ",[73,1143,1144],{"class":87},"isOnline",[73,1146,1147],{"class":116}," } ",[73,1149,991],{"class":631},[73,1151,942],{"class":79},[73,1153,1154],{"class":116},"()\n",[73,1156,1157,1160,1162],{"class":75,"line":133},[73,1158,1159],{"class":116},"\u003C/",[73,1161,1119],{"class":1118},[73,1163,1133],{"class":116},[73,1165,1166],{"class":75,"line":142},[73,1167,130],{"emptyLinePlaceholder":129},[73,1169,1170,1172,1175],{"class":75,"line":157},[73,1171,1115],{"class":116},[73,1173,1174],{"class":1118},"template",[73,1176,1133],{"class":116},[73,1178,1179,1182,1185,1187,1189,1192],{"class":75,"line":165},[73,1180,1181],{"class":116}," \u003C",[73,1183,1184],{"class":1118},"Transition",[73,1186,168],{"class":79},[73,1188,991],{"class":116},[73,1190,1191],{"class":83},"\"slide-down\"",[73,1193,1133],{"class":116},[73,1195,1196,1198],{"class":75,"line":178},[73,1197,1181],{"class":116},[73,1199,1200],{"class":1118},"div\n",[73,1202,1203,1206,1208],{"class":75,"line":191},[73,1204,1205],{"class":79}," v-if",[73,1207,991],{"class":116},[73,1209,1210],{"class":83},"\"!isOnline\"\n",[73,1212,1213,1216,1218],{"class":75,"line":204},[73,1214,1215],{"class":79}," class",[73,1217,991],{"class":116},[73,1219,1220],{"class":83},"\"fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white text-center py-2 text-sm font-medium\"\n",[73,1222,1223,1226,1228],{"class":75,"line":217},[73,1224,1225],{"class":79}," role",[73,1227,991],{"class":116},[73,1229,1230],{"class":83},"\"alert\"\n",[73,1232,1233,1236,1238],{"class":75,"line":230},[73,1234,1235],{"class":79}," aria-live",[73,1237,991],{"class":116},[73,1239,1240],{"class":83},"\"polite\"\n",[73,1242,1243],{"class":75,"line":243},[73,1244,1245],{"class":116}," >\n",[73,1247,1248],{"class":75,"line":256},[73,1249,1250],{"class":116}," You are offline. Some features may not be available.\n",[73,1252,1253,1256,1259],{"class":75,"line":265},[73,1254,1255],{"class":116}," \u003C/",[73,1257,1258],{"class":1118},"div",[73,1260,1133],{"class":116},[73,1262,1263,1265,1267],{"class":75,"line":271},[73,1264,1255],{"class":116},[73,1266,1184],{"class":1118},[73,1268,1133],{"class":116},[73,1270,1271,1273,1275],{"class":75,"line":282},[73,1272,1159],{"class":116},[73,1274,1174],{"class":1118},[73,1276,1133],{"class":116},[15,1278,1279],{},"Include this in your layout and it will automatically appear when connectivity is lost.",[22,1281,1283],{"id":1282},"update-prompts","Update Prompts",[15,1285,1286],{},"When a new version of your app deploys, users with the old version cached need to know an update is available. The module provides hooks for this:",[64,1288,1290],{"className":1101,"code":1289,"language":1103,"meta":69,"style":69},"\u003C!-- components/UpdatePrompt.client.vue -->\n\u003Cscript setup lang=\"ts\">\nconst { needRefresh, updateServiceWorker } = useRegisterSW({\n onRegisteredSW(swUrl) {\n console.log(`Service Worker at: ${swUrl}`)\n },\n})\n\nAsync function update() {\n await updateServiceWorker(true)\n}\n\u003C/script>\n\n\u003Ctemplate>\n \u003CTransition name=\"slide-up\">\n \u003Cdiv\n v-if=\"needRefresh\"\n class=\"fixed bottom-4 right-4 bg-white border border-gray-200 rounded-xl shadow-lg p-4 z-50 flex items-center gap-4\"\n role=\"alert\"\n >\n \u003Cdiv>\n \u003Cp class=\"font-medium text-gray-900\">Update available\u003C/p>\n \u003Cp class=\"text-sm text-gray-500\">A new version of the app is ready.\u003C/p>\n \u003C/div>\n \u003Cbutton\n @click=\"update\"\n class=\"bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700\"\n >\n Refresh\n \u003C/button>\n \u003C/div>\n \u003C/Transition>\n\u003C/template>\n",[59,1291,1292,1297,1313,1337,1350,1371,1375,1380,1384,1397,1411,1415,1423,1427,1435,1450,1456,1465,1474,1482,1486,1494,1514,1534,1542,1549,1559,1568,1572,1577,1586,1594,1602],{"__ignoreMap":69},[73,1293,1294],{"class":75,"line":76},[73,1295,1296],{"class":106},"\u003C!-- components/UpdatePrompt.client.vue -->\n",[73,1298,1299,1301,1303,1305,1307,1309,1311],{"class":75,"line":110},[73,1300,1115],{"class":116},[73,1302,1119],{"class":1118},[73,1304,1122],{"class":79},[73,1306,1125],{"class":79},[73,1308,991],{"class":116},[73,1310,1130],{"class":83},[73,1312,1133],{"class":116},[73,1314,1315,1317,1319,1322,1324,1327,1329,1331,1334],{"class":75,"line":126},[73,1316,1138],{"class":631},[73,1318,1141],{"class":116},[73,1320,1321],{"class":87},"needRefresh",[73,1323,710],{"class":116},[73,1325,1326],{"class":87},"updateServiceWorker",[73,1328,1147],{"class":116},[73,1330,991],{"class":631},[73,1332,1333],{"class":79}," useRegisterSW",[73,1335,1336],{"class":116},"({\n",[73,1338,1339,1342,1344,1347],{"class":75,"line":133},[73,1340,1341],{"class":79}," onRegisteredSW",[73,1343,977],{"class":116},[73,1345,1346],{"class":624},"swUrl",[73,1348,1349],{"class":116},") {\n",[73,1351,1352,1355,1358,1360,1363,1365,1368],{"class":75,"line":142},[73,1353,1354],{"class":116}," console.",[73,1356,1357],{"class":79},"log",[73,1359,977],{"class":116},[73,1361,1362],{"class":83},"`Service Worker at: ${",[73,1364,1346],{"class":116},[73,1366,1367],{"class":83},"}`",[73,1369,1370],{"class":116},")\n",[73,1372,1373],{"class":75,"line":157},[73,1374,307],{"class":116},[73,1376,1377],{"class":75,"line":165},[73,1378,1379],{"class":116},"})\n",[73,1381,1382],{"class":75,"line":178},[73,1383,130],{"emptyLinePlaceholder":129},[73,1385,1386,1389,1392,1395],{"class":75,"line":191},[73,1387,1388],{"class":116},"Async ",[73,1390,1391],{"class":631},"function",[73,1393,1394],{"class":79}," update",[73,1396,945],{"class":116},[73,1398,1399,1402,1405,1407,1409],{"class":75,"line":204},[73,1400,1401],{"class":631}," await",[73,1403,1404],{"class":79}," updateServiceWorker",[73,1406,977],{"class":116},[73,1408,437],{"class":87},[73,1410,1370],{"class":116},[73,1412,1413],{"class":75,"line":217},[73,1414,1098],{"class":116},[73,1416,1417,1419,1421],{"class":75,"line":230},[73,1418,1159],{"class":116},[73,1420,1119],{"class":1118},[73,1422,1133],{"class":116},[73,1424,1425],{"class":75,"line":243},[73,1426,130],{"emptyLinePlaceholder":129},[73,1428,1429,1431,1433],{"class":75,"line":256},[73,1430,1115],{"class":116},[73,1432,1174],{"class":1118},[73,1434,1133],{"class":116},[73,1436,1437,1439,1441,1443,1445,1448],{"class":75,"line":265},[73,1438,1181],{"class":116},[73,1440,1184],{"class":1118},[73,1442,168],{"class":79},[73,1444,991],{"class":116},[73,1446,1447],{"class":83},"\"slide-up\"",[73,1449,1133],{"class":116},[73,1451,1452,1454],{"class":75,"line":271},[73,1453,1181],{"class":116},[73,1455,1200],{"class":1118},[73,1457,1458,1460,1462],{"class":75,"line":282},[73,1459,1205],{"class":79},[73,1461,991],{"class":116},[73,1463,1464],{"class":83},"\"needRefresh\"\n",[73,1466,1467,1469,1471],{"class":75,"line":293},[73,1468,1215],{"class":79},[73,1470,991],{"class":116},[73,1472,1473],{"class":83},"\"fixed bottom-4 right-4 bg-white border border-gray-200 rounded-xl shadow-lg p-4 z-50 flex items-center gap-4\"\n",[73,1475,1476,1478,1480],{"class":75,"line":304},[73,1477,1225],{"class":79},[73,1479,991],{"class":116},[73,1481,1230],{"class":83},[73,1483,1484],{"class":75,"line":310},[73,1485,1245],{"class":116},[73,1487,1488,1490,1492],{"class":75,"line":315},[73,1489,1181],{"class":116},[73,1491,1258],{"class":1118},[73,1493,1133],{"class":116},[73,1495,1496,1498,1500,1502,1504,1507,1510,1512],{"class":75,"line":325},[73,1497,1181],{"class":116},[73,1499,15],{"class":1118},[73,1501,1215],{"class":79},[73,1503,991],{"class":116},[73,1505,1506],{"class":83},"\"font-medium text-gray-900\"",[73,1508,1509],{"class":116},">Update available\u003C/",[73,1511,15],{"class":1118},[73,1513,1133],{"class":116},[73,1515,1516,1518,1520,1522,1524,1527,1530,1532],{"class":75,"line":335},[73,1517,1181],{"class":116},[73,1519,15],{"class":1118},[73,1521,1215],{"class":79},[73,1523,991],{"class":116},[73,1525,1526],{"class":83},"\"text-sm text-gray-500\"",[73,1528,1529],{"class":116},">A new version of the app is ready.\u003C/",[73,1531,15],{"class":1118},[73,1533,1133],{"class":116},[73,1535,1536,1538,1540],{"class":75,"line":344},[73,1537,1255],{"class":116},[73,1539,1258],{"class":1118},[73,1541,1133],{"class":116},[73,1543,1544,1546],{"class":75,"line":349},[73,1545,1181],{"class":116},[73,1547,1548],{"class":1118},"button\n",[73,1550,1551,1554,1556],{"class":75,"line":354},[73,1552,1553],{"class":79}," @click",[73,1555,991],{"class":116},[73,1557,1558],{"class":83},"\"update\"\n",[73,1560,1561,1563,1565],{"class":75,"line":363},[73,1562,1215],{"class":79},[73,1564,991],{"class":116},[73,1566,1567],{"class":83},"\"bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700\"\n",[73,1569,1570],{"class":75,"line":372},[73,1571,1245],{"class":116},[73,1573,1574],{"class":75,"line":381},[73,1575,1576],{"class":116}," Refresh\n",[73,1578,1579,1581,1584],{"class":75,"line":392},[73,1580,1255],{"class":116},[73,1582,1583],{"class":1118},"button",[73,1585,1133],{"class":116},[73,1587,1588,1590,1592],{"class":75,"line":397},[73,1589,1255],{"class":116},[73,1591,1258],{"class":1118},[73,1593,1133],{"class":116},[73,1595,1596,1598,1600],{"class":75,"line":403},[73,1597,1255],{"class":116},[73,1599,1184],{"class":1118},[73,1601,1133],{"class":116},[73,1603,1604,1606,1608],{"class":75,"line":408},[73,1605,1159],{"class":116},[73,1607,1174],{"class":1118},[73,1609,1133],{"class":116},[22,1611,1613],{"id":1612},"install-prompt","Install Prompt",[15,1615,1616],{},"Browsers show an install prompt when your PWA meets the install criteria. You can intercept this prompt and show your own UI at a better time:",[64,1618,1620],{"className":97,"code":1619,"language":99,"meta":69,"style":69},"// composables/usePWAInstall.ts\nexport function usePWAInstall() {\n const installPrompt = ref\u003CBeforeInstallPromptEvent | null>(null)\n const isInstalled = ref(window.matchMedia('(display-mode: standalone)').matches)\n\n window.addEventListener('beforeinstallprompt', (e) => {\n e.preventDefault()\n installPrompt.value = e as BeforeInstallPromptEvent\n })\n\n window.addEventListener('appinstalled', () => {\n isInstalled.value = true\n installPrompt.value = null\n })\n\n async function promptInstall() {\n if (!installPrompt.value) return\n\n installPrompt.value.prompt()\n const { outcome } = await installPrompt.value.userChoice\n\n if (outcome === 'accepted') {\n installPrompt.value = null\n }\n }\n\n return {\n canInstall: computed(() => !isInstalled.value && installPrompt.value !== null),\n isInstalled: readonly(isInstalled),\n promptInstall,\n }\n}\n",[59,1621,1622,1627,1638,1667,1692,1696,1720,1730,1746,1750,1754,1771,1781,1790,1794,1798,1810,1827,1831,1841,1859,1863,1877,1885,1890,1894,1898,1904,1935,1945,1950,1954],{"__ignoreMap":69},[73,1623,1624],{"class":75,"line":76},[73,1625,1626],{"class":106},"// composables/usePWAInstall.ts\n",[73,1628,1629,1631,1633,1636],{"class":75,"line":110},[73,1630,936],{"class":631},[73,1632,939],{"class":631},[73,1634,1635],{"class":79}," usePWAInstall",[73,1637,945],{"class":116},[73,1639,1640,1642,1644,1646,1648,1650,1653,1656,1659,1662,1665],{"class":75,"line":126},[73,1641,950],{"class":631},[73,1643,471],{"class":87},[73,1645,956],{"class":631},[73,1647,959],{"class":79},[73,1649,1115],{"class":116},[73,1651,1652],{"class":79},"BeforeInstallPromptEvent",[73,1654,1655],{"class":631}," |",[73,1657,1658],{"class":87}," null",[73,1660,1661],{"class":116},">(",[73,1663,1664],{"class":87},"null",[73,1666,1370],{"class":116},[73,1668,1669,1671,1674,1676,1678,1681,1684,1686,1689],{"class":75,"line":133},[73,1670,950],{"class":631},[73,1672,1673],{"class":87}," isInstalled",[73,1675,956],{"class":631},[73,1677,959],{"class":79},[73,1679,1680],{"class":116},"(window.",[73,1682,1683],{"class":79},"matchMedia",[73,1685,977],{"class":116},[73,1687,1688],{"class":83},"'(display-mode: standalone)'",[73,1690,1691],{"class":116},").matches)\n",[73,1693,1694],{"class":75,"line":142},[73,1695,130],{"emptyLinePlaceholder":129},[73,1697,1698,1700,1702,1704,1707,1710,1713,1716,1718],{"class":75,"line":157},[73,1699,971],{"class":116},[73,1701,974],{"class":79},[73,1703,977],{"class":116},[73,1705,1706],{"class":83},"'beforeinstallprompt'",[73,1708,1709],{"class":116},", (",[73,1711,1712],{"class":624},"e",[73,1714,1715],{"class":116},") ",[73,1717,632],{"class":631},[73,1719,268],{"class":116},[73,1721,1722,1725,1728],{"class":75,"line":165},[73,1723,1724],{"class":116}," e.",[73,1726,1727],{"class":79},"preventDefault",[73,1729,1154],{"class":116},[73,1731,1732,1735,1737,1740,1743],{"class":75,"line":178},[73,1733,1734],{"class":116}," installPrompt.value ",[73,1736,991],{"class":631},[73,1738,1739],{"class":116}," e ",[73,1741,1742],{"class":631},"as",[73,1744,1745],{"class":79}," BeforeInstallPromptEvent\n",[73,1747,1748],{"class":75,"line":191},[73,1749,997],{"class":116},[73,1751,1752],{"class":75,"line":204},[73,1753,130],{"emptyLinePlaceholder":129},[73,1755,1756,1758,1760,1762,1765,1767,1769],{"class":75,"line":217},[73,1757,971],{"class":116},[73,1759,974],{"class":79},[73,1761,977],{"class":116},[73,1763,1764],{"class":83},"'appinstalled'",[73,1766,983],{"class":116},[73,1768,632],{"class":631},[73,1770,268],{"class":116},[73,1772,1773,1776,1778],{"class":75,"line":230},[73,1774,1775],{"class":116}," isInstalled.value ",[73,1777,991],{"class":631},[73,1779,1780],{"class":87}," true\n",[73,1782,1783,1785,1787],{"class":75,"line":243},[73,1784,1734],{"class":116},[73,1786,991],{"class":631},[73,1788,1789],{"class":87}," null\n",[73,1791,1792],{"class":75,"line":256},[73,1793,997],{"class":116},[73,1795,1796],{"class":75,"line":265},[73,1797,130],{"emptyLinePlaceholder":129},[73,1799,1800,1803,1805,1808],{"class":75,"line":271},[73,1801,1802],{"class":631}," async",[73,1804,939],{"class":631},[73,1806,1807],{"class":79}," promptInstall",[73,1809,945],{"class":116},[73,1811,1812,1815,1818,1821,1824],{"class":75,"line":282},[73,1813,1814],{"class":631}," if",[73,1816,1817],{"class":116}," (",[73,1819,1820],{"class":631},"!",[73,1822,1823],{"class":116},"installPrompt.value) ",[73,1825,1826],{"class":631},"return\n",[73,1828,1829],{"class":75,"line":293},[73,1830,130],{"emptyLinePlaceholder":129},[73,1832,1833,1836,1839],{"class":75,"line":304},[73,1834,1835],{"class":116}," installPrompt.value.",[73,1837,1838],{"class":79},"prompt",[73,1840,1154],{"class":116},[73,1842,1843,1845,1847,1850,1852,1854,1856],{"class":75,"line":310},[73,1844,950],{"class":631},[73,1846,1141],{"class":116},[73,1848,1849],{"class":87},"outcome",[73,1851,1147],{"class":116},[73,1853,991],{"class":631},[73,1855,1401],{"class":631},[73,1857,1858],{"class":116}," installPrompt.value.userChoice\n",[73,1860,1861],{"class":75,"line":315},[73,1862,130],{"emptyLinePlaceholder":129},[73,1864,1865,1867,1870,1872,1875],{"class":75,"line":325},[73,1866,1814],{"class":631},[73,1868,1869],{"class":116}," (outcome ",[73,1871,638],{"class":631},[73,1873,1874],{"class":83}," 'accepted'",[73,1876,1349],{"class":116},[73,1878,1879,1881,1883],{"class":75,"line":335},[73,1880,1734],{"class":116},[73,1882,991],{"class":631},[73,1884,1789],{"class":87},[73,1886,1887],{"class":75,"line":344},[73,1888,1889],{"class":116}," }\n",[73,1891,1892],{"class":75,"line":349},[73,1893,1889],{"class":116},[73,1895,1896],{"class":75,"line":354},[73,1897,130],{"emptyLinePlaceholder":129},[73,1899,1900,1902],{"class":75,"line":363},[73,1901,1084],{"class":631},[73,1903,268],{"class":116},[73,1905,1906,1909,1912,1914,1916,1919,1922,1925,1927,1930,1932],{"class":75,"line":372},[73,1907,1908],{"class":116}," canInstall: ",[73,1910,1911],{"class":79},"computed",[73,1913,1033],{"class":116},[73,1915,632],{"class":631},[73,1917,1918],{"class":631}," !",[73,1920,1921],{"class":116},"isInstalled.value ",[73,1923,1924],{"class":631},"&&",[73,1926,1734],{"class":116},[73,1928,1929],{"class":631},"!==",[73,1931,1658],{"class":87},[73,1933,1934],{"class":116},"),\n",[73,1936,1937,1940,1942],{"class":75,"line":381},[73,1938,1939],{"class":116}," isInstalled: ",[73,1941,1090],{"class":79},[73,1943,1944],{"class":116},"(isInstalled),\n",[73,1946,1947],{"class":75,"line":392},[73,1948,1949],{"class":116}," promptInstall,\n",[73,1951,1952],{"class":75,"line":397},[73,1953,1889],{"class":116},[73,1955,1956],{"class":75,"line":403},[73,1957,1098],{"class":116},[15,1959,1960],{},"Show the install button contextually — after a user has interacted with the app meaningfully, not immediately on first visit. First-visit install prompts are ignored almost universally.",[22,1962,1964],{"id":1963},"push-notifications","Push Notifications",[15,1966,1967],{},"Push notifications require server-side integration through the Web Push API. Generate VAPID keys and store them securely:",[64,1969,1971],{"className":66,"code":1970,"language":68,"meta":69,"style":69},"npx web-push generate-vapid-keys\n",[59,1972,1973],{"__ignoreMap":69},[73,1974,1975,1978,1981],{"class":75,"line":76},[73,1976,1977],{"class":79},"npx",[73,1979,1980],{"class":83}," web-push",[73,1982,1983],{"class":83}," generate-vapid-keys\n",[15,1985,1986],{},"Subscribe users in the browser:",[64,1988,1990],{"className":97,"code":1989,"language":99,"meta":69,"style":69},"async function subscribeToPush() {\n const registration = await navigator.serviceWorker.ready\n\n const subscription = await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(\n useRuntimeConfig().public.vapidPublicKey\n ),\n })\n\n await $fetch('/api/push/subscribe', {\n method: 'POST',\n body: subscription.toJSON(),\n })\n}\n",[59,1991,1992,2004,2018,2022,2041,2050,2061,2069,2074,2078,2082,2097,2107,2118,2122],{"__ignoreMap":69},[73,1993,1994,1997,1999,2002],{"class":75,"line":76},[73,1995,1996],{"class":631},"async",[73,1998,939],{"class":631},[73,2000,2001],{"class":79}," subscribeToPush",[73,2003,945],{"class":116},[73,2005,2006,2008,2011,2013,2015],{"class":75,"line":110},[73,2007,950],{"class":631},[73,2009,2010],{"class":87}," registration",[73,2012,956],{"class":631},[73,2014,1401],{"class":631},[73,2016,2017],{"class":116}," navigator.serviceWorker.ready\n",[73,2019,2020],{"class":75,"line":126},[73,2021,130],{"emptyLinePlaceholder":129},[73,2023,2024,2026,2029,2031,2033,2036,2039],{"class":75,"line":133},[73,2025,950],{"class":631},[73,2027,2028],{"class":87}," subscription",[73,2030,956],{"class":631},[73,2032,1401],{"class":631},[73,2034,2035],{"class":116}," registration.pushManager.",[73,2037,2038],{"class":79},"subscribe",[73,2040,1336],{"class":116},[73,2042,2043,2046,2048],{"class":75,"line":142},[73,2044,2045],{"class":116}," userVisibleOnly: ",[73,2047,437],{"class":87},[73,2049,154],{"class":116},[73,2051,2052,2055,2058],{"class":75,"line":157},[73,2053,2054],{"class":116}," applicationServerKey: ",[73,2056,2057],{"class":79},"urlBase64ToUint8Array",[73,2059,2060],{"class":116},"(\n",[73,2062,2063,2066],{"class":75,"line":165},[73,2064,2065],{"class":79}," useRuntimeConfig",[73,2067,2068],{"class":116},"().public.vapidPublicKey\n",[73,2070,2071],{"class":75,"line":178},[73,2072,2073],{"class":116}," ),\n",[73,2075,2076],{"class":75,"line":191},[73,2077,997],{"class":116},[73,2079,2080],{"class":75,"line":204},[73,2081,130],{"emptyLinePlaceholder":129},[73,2083,2084,2086,2089,2091,2094],{"class":75,"line":217},[73,2085,1401],{"class":631},[73,2087,2088],{"class":79}," $fetch",[73,2090,977],{"class":116},[73,2092,2093],{"class":83},"'/api/push/subscribe'",[73,2095,2096],{"class":116},", {\n",[73,2098,2099,2102,2105],{"class":75,"line":230},[73,2100,2101],{"class":116}," method: ",[73,2103,2104],{"class":83},"'POST'",[73,2106,154],{"class":116},[73,2108,2109,2112,2115],{"class":75,"line":243},[73,2110,2111],{"class":116}," body: subscription.",[73,2113,2114],{"class":79},"toJSON",[73,2116,2117],{"class":116},"(),\n",[73,2119,2120],{"class":75,"line":256},[73,2121,997],{"class":116},[73,2123,2124],{"class":75,"line":265},[73,2125,1098],{"class":116},[15,2127,2128],{},"Send from your server:",[64,2130,2132],{"className":97,"code":2131,"language":99,"meta":69,"style":69},"// server/api/push/send.post.ts\nimport webpush from 'web-push'\n\nWebpush.setVapidDetails(\n 'mailto:admin@yourdomain.com',\n process.env.VAPID_PUBLIC_KEY!,\n process.env.VAPID_PRIVATE_KEY!\n)\n\nExport default defineEventHandler(async (event) => {\n const { subscription, payload } = await readBody(event)\n await webpush.sendNotification(subscription, JSON.stringify(payload))\n return { success: true }\n})\n",[59,2133,2134,2139,2153,2157,2167,2174,2186,2196,2200,2204,2230,2256,2281,2292],{"__ignoreMap":69},[73,2135,2136],{"class":75,"line":76},[73,2137,2138],{"class":106},"// server/api/push/send.post.ts\n",[73,2140,2141,2144,2147,2150],{"class":75,"line":110},[73,2142,2143],{"class":631},"import",[73,2145,2146],{"class":116}," webpush ",[73,2148,2149],{"class":631},"from",[73,2151,2152],{"class":83}," 'web-push'\n",[73,2154,2155],{"class":75,"line":126},[73,2156,130],{"emptyLinePlaceholder":129},[73,2158,2159,2162,2165],{"class":75,"line":133},[73,2160,2161],{"class":116},"Webpush.",[73,2163,2164],{"class":79},"setVapidDetails",[73,2166,2060],{"class":116},[73,2168,2169,2172],{"class":75,"line":142},[73,2170,2171],{"class":83}," 'mailto:admin@yourdomain.com'",[73,2173,154],{"class":116},[73,2175,2176,2179,2182,2184],{"class":75,"line":157},[73,2177,2178],{"class":116}," process.env.",[73,2180,2181],{"class":87},"VAPID_PUBLIC_KEY",[73,2183,1820],{"class":631},[73,2185,154],{"class":116},[73,2187,2188,2190,2193],{"class":75,"line":165},[73,2189,2178],{"class":116},[73,2191,2192],{"class":87},"VAPID_PRIVATE_KEY",[73,2194,2195],{"class":631},"!\n",[73,2197,2198],{"class":75,"line":178},[73,2199,1370],{"class":116},[73,2201,2202],{"class":75,"line":191},[73,2203,130],{"emptyLinePlaceholder":129},[73,2205,2206,2209,2212,2215,2217,2219,2221,2224,2226,2228],{"class":75,"line":204},[73,2207,2208],{"class":116},"Export ",[73,2210,2211],{"class":631},"default",[73,2213,2214],{"class":79}," defineEventHandler",[73,2216,977],{"class":116},[73,2218,1996],{"class":631},[73,2220,1817],{"class":116},[73,2222,2223],{"class":624},"event",[73,2225,1715],{"class":116},[73,2227,632],{"class":631},[73,2229,268],{"class":116},[73,2231,2232,2234,2236,2239,2241,2244,2246,2248,2250,2253],{"class":75,"line":217},[73,2233,950],{"class":631},[73,2235,1141],{"class":116},[73,2237,2238],{"class":87},"subscription",[73,2240,710],{"class":116},[73,2242,2243],{"class":87},"payload",[73,2245,1147],{"class":116},[73,2247,991],{"class":631},[73,2249,1401],{"class":631},[73,2251,2252],{"class":79}," readBody",[73,2254,2255],{"class":116},"(event)\n",[73,2257,2258,2260,2263,2266,2269,2272,2275,2278],{"class":75,"line":230},[73,2259,1401],{"class":631},[73,2261,2262],{"class":116}," webpush.",[73,2264,2265],{"class":79},"sendNotification",[73,2267,2268],{"class":116},"(subscription, ",[73,2270,2271],{"class":87},"JSON",[73,2273,2274],{"class":116},".",[73,2276,2277],{"class":79},"stringify",[73,2279,2280],{"class":116},"(payload))\n",[73,2282,2283,2285,2288,2290],{"class":75,"line":243},[73,2284,1084],{"class":631},[73,2286,2287],{"class":116}," { success: ",[73,2289,437],{"class":87},[73,2291,1889],{"class":116},[73,2293,2294],{"class":75,"line":256},[73,2295,1379],{"class":116},[22,2297,2299],{"id":2298},"performance-and-lighthouse","Performance and Lighthouse",[15,2301,2302],{},"A PWA should score 90+ on the Lighthouse PWA audit. The module handles most requirements automatically, but verify:",[2304,2305,2306,2310,2313,2316,2319],"ul",{},[2307,2308,2309],"li",{},"The manifest is valid and includes required fields",[2307,2311,2312],{},"Icons are present at 192x192 and 512x512 minimum",[2307,2314,2315],{},"The service worker is registered and active",[2307,2317,2318],{},"The app works offline (test with DevTools > Network > Offline)",[2307,2320,2321],{},"The install prompt appears in Chrome after meeting criteria",[15,2323,2324],{},"PWAs work best for applications users return to repeatedly — productivity tools, reference apps, social platforms. For single-visit content sites, the PWA overhead is not worth the complexity. Match the investment to the use case.",[2326,2327],"hr",{},[15,2329,2330,2331,2274],{},"Building a PWA with Nuxt and need help with the service worker strategy or push notification setup? Book a call and we can work through the architecture together: ",[2332,2333,2337],"a",{"href":2334,"rel":2335},"https://calendly.com/jamesrossjr",[2336],"nofollow","calendly.com/jamesrossjr",[2326,2339],{},[22,2341,2343],{"id":2342},"keep-reading","Keep Reading",[2304,2345,2346,2352,2358,2364],{},[2307,2347,2348],{},[2332,2349,2351],{"href":2350},"/blog/nuxt-content-module-guide","Building a Blog With Nuxt Content: The Complete Guide",[2307,2353,2354],{},[2332,2355,2357],{"href":2356},"/blog/nuxt-api-routes-nitro","Nuxt API Routes With Nitro: Building Your Backend in the Same Repo",[2307,2359,2360],{},[2332,2361,2363],{"href":2362},"/blog/nuxt-internationalization","i18n in Nuxt: Adding Multi-Language Support Without the Pain",[2307,2365,2366],{},[2332,2367,2369],{"href":2368},"/blog/nuxt-authentication-guide","Authentication in Nuxt: Patterns That Actually Scale",[2371,2372,2373],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":69,"searchDepth":126,"depth":126,"links":2375},[2376,2377,2378,2379,2380,2381,2382,2383,2384],{"id":24,"depth":110,"text":25},{"id":53,"depth":110,"text":54},{"id":565,"depth":110,"text":566},{"id":917,"depth":110,"text":918},{"id":1282,"depth":110,"text":1283},{"id":1612,"depth":110,"text":1613},{"id":1963,"depth":110,"text":1964},{"id":2298,"depth":110,"text":2299},{"id":2342,"depth":110,"text":2343},"Engineering","2026-03-03","How to build a production-ready Progressive Web App with Nuxt — service workers, offline support, push notifications, install prompts, and the @vite-pwa/nuxt module.","md",false,null,[2392,2393],"Nuxt PWA","progressive web app Nuxt",{},"/blog/nuxt-pwa-guide",{"title":7,"description":2387},"blog/nuxt-pwa-guide",[2399,2400,2401],"Nuxt","PWA","Offline","ZQ5ylEItWHh092CxDGKJLsp1WrhaSaqGg6xjVryVX10",{"id":2404,"title":2405,"author":2406,"body":2407,"category":2385,"date":2386,"description":3882,"extension":2388,"featured":2389,"image":2390,"keywords":3883,"meta":3886,"navigation":129,"path":3887,"readTime":165,"seo":3888,"stem":3889,"tags":3890,"__hash__":3893},"blog/blog/nuxt-seo-optimization.md","Nuxt SEO: Everything You Need for Ranking in 2026",{"name":9,"bio":10},{"type":12,"value":2408,"toc":3870},[2409,2412,2415,2419,2426,2534,2539,2542,2734,2737,2741,2748,2769,2775,2859,2863,2870,3024,3031,3038,3042,3045,3048,3189,3192,3265,3268,3272,3275,3281,3299,3391,3404,3514,3520,3524,3527,3580,3583,3586,3635,3639,3642,3694,3701,3705,3708,3813,3819,3823,3826,3829,3832,3835,3837,3843,3845,3847,3867],[15,2410,2411],{},"SEO is where a lot of frontend frameworks fall short, and where Nuxt genuinely shines. The combination of server-side rendering, a well-designed head management API, and a growing ecosystem of SEO-focused modules means you can build a technically excellent site for search without fighting your framework.",[15,2413,2414],{},"But having the tools available and using them correctly are different things. I have audited dozens of Nuxt sites that had all the right modules installed and still had preventable SEO problems. This guide is about using the tools correctly.",[22,2416,2418],{"id":2417},"the-foundation-proper-meta-tags","The Foundation: Proper Meta Tags",[15,2420,2421,2422,2425],{},"Every page needs a unique, descriptive title and a compelling meta description. In Nuxt, you set these with ",[59,2423,2424],{},"useSeoMeta",":",[64,2427,2429],{"className":1101,"code":2428,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nuseSeoMeta({\n title: 'Product Name — Your Brand',\n description: 'Your unique, compelling 150-160 character description goes here. Write for humans, not bots.',\n ogTitle: 'Product Name — Your Brand',\n ogDescription: 'Open Graph description for social sharing.',\n ogImage: 'https://yourdomain.com/og/product-name.png',\n ogUrl: 'https://yourdomain.com/products/product-name',\n twitterCard: 'summary_large_image',\n})\n\u003C/script>\n",[59,2430,2431,2447,2453,2463,2473,2482,2492,2502,2512,2522,2526],{"__ignoreMap":69},[73,2432,2433,2435,2437,2439,2441,2443,2445],{"class":75,"line":76},[73,2434,1115],{"class":116},[73,2436,1119],{"class":1118},[73,2438,1122],{"class":79},[73,2440,1125],{"class":79},[73,2442,991],{"class":116},[73,2444,1130],{"class":83},[73,2446,1133],{"class":116},[73,2448,2449,2451],{"class":75,"line":110},[73,2450,2424],{"class":79},[73,2452,1336],{"class":116},[73,2454,2455,2458,2461],{"class":75,"line":126},[73,2456,2457],{"class":116}," title: ",[73,2459,2460],{"class":83},"'Product Name — Your Brand'",[73,2462,154],{"class":116},[73,2464,2465,2468,2471],{"class":75,"line":133},[73,2466,2467],{"class":116}," description: ",[73,2469,2470],{"class":83},"'Your unique, compelling 150-160 character description goes here. Write for humans, not bots.'",[73,2472,154],{"class":116},[73,2474,2475,2478,2480],{"class":75,"line":142},[73,2476,2477],{"class":116}," ogTitle: ",[73,2479,2460],{"class":83},[73,2481,154],{"class":116},[73,2483,2484,2487,2490],{"class":75,"line":157},[73,2485,2486],{"class":116}," ogDescription: ",[73,2488,2489],{"class":83},"'Open Graph description for social sharing.'",[73,2491,154],{"class":116},[73,2493,2494,2497,2500],{"class":75,"line":165},[73,2495,2496],{"class":116}," ogImage: ",[73,2498,2499],{"class":83},"'https://yourdomain.com/og/product-name.png'",[73,2501,154],{"class":116},[73,2503,2504,2507,2510],{"class":75,"line":178},[73,2505,2506],{"class":116}," ogUrl: ",[73,2508,2509],{"class":83},"'https://yourdomain.com/products/product-name'",[73,2511,154],{"class":116},[73,2513,2514,2517,2520],{"class":75,"line":191},[73,2515,2516],{"class":116}," twitterCard: ",[73,2518,2519],{"class":83},"'summary_large_image'",[73,2521,154],{"class":116},[73,2523,2524],{"class":75,"line":204},[73,2525,1379],{"class":116},[73,2527,2528,2530,2532],{"class":75,"line":217},[73,2529,1159],{"class":116},[73,2531,1119],{"class":1118},[73,2533,1133],{"class":116},[15,2535,57,2536,2538],{},[59,2537,2424],{}," composable is typed — it knows which meta tags accept which values and will warn you about incorrect usage. Use it on every page, not just the homepage.",[15,2540,2541],{},"For dynamic pages like blog posts or product detail pages, compute the values from your data:",[64,2543,2545],{"className":1101,"code":2544,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nconst { data: post } = await useAsyncData('post', () =>\n queryCollection('blog').path(useRoute().path).first()\n)\n\nUseSeoMeta({\n title: () => `${post.value?.title} — James Ross Jr.`,\n description: () => post.value?.description,\n ogTitle: () => post.value?.title,\n ogImage: () => `https://jamesrossjr.com/og/${post.value?.slug}.png`,\n})\n\u003C/script>\n",[59,2546,2547,2563,2596,2625,2629,2633,2640,2671,2682,2694,2722,2726],{"__ignoreMap":69},[73,2548,2549,2551,2553,2555,2557,2559,2561],{"class":75,"line":76},[73,2550,1115],{"class":116},[73,2552,1119],{"class":1118},[73,2554,1122],{"class":79},[73,2556,1125],{"class":79},[73,2558,991],{"class":116},[73,2560,1130],{"class":83},[73,2562,1133],{"class":116},[73,2564,2565,2567,2569,2572,2574,2577,2579,2581,2583,2586,2588,2591,2593],{"class":75,"line":110},[73,2566,1138],{"class":631},[73,2568,1141],{"class":116},[73,2570,2571],{"class":624},"data",[73,2573,148],{"class":116},[73,2575,2576],{"class":87},"post",[73,2578,1147],{"class":116},[73,2580,991],{"class":631},[73,2582,1401],{"class":631},[73,2584,2585],{"class":79}," useAsyncData",[73,2587,977],{"class":116},[73,2589,2590],{"class":83},"'post'",[73,2592,983],{"class":116},[73,2594,2595],{"class":631},"=>\n",[73,2597,2598,2601,2603,2606,2609,2612,2614,2617,2620,2623],{"class":75,"line":126},[73,2599,2600],{"class":79}," queryCollection",[73,2602,977],{"class":116},[73,2604,2605],{"class":83},"'blog'",[73,2607,2608],{"class":116},").",[73,2610,2611],{"class":79},"path",[73,2613,977],{"class":116},[73,2615,2616],{"class":79},"useRoute",[73,2618,2619],{"class":116},"().path).",[73,2621,2622],{"class":79},"first",[73,2624,1154],{"class":116},[73,2626,2627],{"class":75,"line":133},[73,2628,1370],{"class":116},[73,2630,2631],{"class":75,"line":142},[73,2632,130],{"emptyLinePlaceholder":129},[73,2634,2635,2638],{"class":75,"line":157},[73,2636,2637],{"class":79},"UseSeoMeta",[73,2639,1336],{"class":116},[73,2641,2642,2645,2648,2650,2653,2655,2657,2660,2663,2666,2669],{"class":75,"line":165},[73,2643,2644],{"class":79}," title",[73,2646,2647],{"class":116},": () ",[73,2649,632],{"class":631},[73,2651,2652],{"class":83}," `${",[73,2654,2576],{"class":116},[73,2656,2274],{"class":83},[73,2658,2659],{"class":116},"value",[73,2661,2662],{"class":83},"?.",[73,2664,2665],{"class":116},"title",[73,2667,2668],{"class":83},"} — James Ross Jr.`",[73,2670,154],{"class":116},[73,2672,2673,2675,2677,2679],{"class":75,"line":178},[73,2674,194],{"class":79},[73,2676,2647],{"class":116},[73,2678,632],{"class":631},[73,2680,2681],{"class":116}," post.value?.description,\n",[73,2683,2684,2687,2689,2691],{"class":75,"line":191},[73,2685,2686],{"class":79}," ogTitle",[73,2688,2647],{"class":116},[73,2690,632],{"class":631},[73,2692,2693],{"class":116}," post.value?.title,\n",[73,2695,2696,2699,2701,2703,2706,2708,2710,2712,2714,2717,2720],{"class":75,"line":204},[73,2697,2698],{"class":79}," ogImage",[73,2700,2647],{"class":116},[73,2702,632],{"class":631},[73,2704,2705],{"class":83}," `https://jamesrossjr.com/og/${",[73,2707,2576],{"class":116},[73,2709,2274],{"class":83},[73,2711,2659],{"class":116},[73,2713,2662],{"class":83},[73,2715,2716],{"class":116},"slug",[73,2718,2719],{"class":83},"}.png`",[73,2721,154],{"class":116},[73,2723,2724],{"class":75,"line":217},[73,2725,1379],{"class":116},[73,2727,2728,2730,2732],{"class":75,"line":230},[73,2729,1159],{"class":116},[73,2731,1119],{"class":1118},[73,2733,1133],{"class":116},[15,2735,2736],{},"The function form ensures the values update reactively when your data loads.",[22,2738,2740],{"id":2739},"the-nuxtjsseo-module","The @nuxtjs/seo Module",[15,2742,2743,2744,2747],{},"Rather than manually wiring every SEO concern, the ",[59,2745,2746],{},"@nuxtjs/seo"," module consolidates the most important tools into one:",[64,2749,2751],{"className":66,"code":2750,"language":68,"meta":69,"style":69},"npx nuxi module add seo\n",[59,2752,2753],{"__ignoreMap":69},[73,2754,2755,2757,2760,2763,2766],{"class":75,"line":76},[73,2756,1977],{"class":79},[73,2758,2759],{"class":83}," nuxi",[73,2761,2762],{"class":83}," module",[73,2764,2765],{"class":83}," add",[73,2767,2768],{"class":83}," seo\n",[15,2770,2771,2772,2425],{},"This brings in robots.txt generation, sitemap generation, Open Graph tags, Twitter card support, and schema.org structured data — all configured from a single place in ",[59,2773,2774],{},"nuxt.config.ts",[64,2776,2778],{"className":97,"code":2777,"language":99,"meta":69,"style":69},"seo: {\n redirectToCanonicalSiteUrl: true,\n},\nsite: {\n url: 'https://jamesrossjr.com',\n name: 'James Ross Jr.',\n description: 'Strategic Systems Architect & Enterprise Software Developer',\n defaultLocale: 'en',\n},\n",[59,2779,2780,2787,2798,2802,2809,2821,2832,2843,2855],{"__ignoreMap":69},[73,2781,2782,2785],{"class":75,"line":76},[73,2783,2784],{"class":79},"seo",[73,2786,139],{"class":116},[73,2788,2789,2792,2794,2796],{"class":75,"line":110},[73,2790,2791],{"class":79}," redirectToCanonicalSiteUrl",[73,2793,148],{"class":116},[73,2795,437],{"class":87},[73,2797,154],{"class":116},[73,2799,2800],{"class":75,"line":126},[73,2801,562],{"class":116},[73,2803,2804,2807],{"class":75,"line":133},[73,2805,2806],{"class":79},"site",[73,2808,139],{"class":116},[73,2810,2811,2814,2816,2819],{"class":75,"line":142},[73,2812,2813],{"class":79}," url",[73,2815,148],{"class":116},[73,2817,2818],{"class":83},"'https://jamesrossjr.com'",[73,2820,154],{"class":116},[73,2822,2823,2825,2827,2830],{"class":75,"line":157},[73,2824,168],{"class":79},[73,2826,148],{"class":116},[73,2828,2829],{"class":83},"'James Ross Jr.'",[73,2831,154],{"class":116},[73,2833,2834,2836,2838,2841],{"class":75,"line":165},[73,2835,194],{"class":79},[73,2837,148],{"class":116},[73,2839,2840],{"class":83},"'Strategic Systems Architect & Enterprise Software Developer'",[73,2842,154],{"class":116},[73,2844,2845,2848,2850,2853],{"class":75,"line":178},[73,2846,2847],{"class":79}," defaultLocale",[73,2849,148],{"class":116},[73,2851,2852],{"class":83},"'en'",[73,2854,154],{"class":116},[73,2856,2857],{"class":75,"line":191},[73,2858,562],{"class":116},[22,2860,2862],{"id":2861},"sitemaps-that-actually-help","Sitemaps That Actually Help",[15,2864,2865,2866,2869],{},"A proper sitemap tells search engines what pages exist, when they were last modified, and their relative importance. The ",[59,2867,2868],{},"@nuxtjs/sitemap"," module generates this automatically:",[64,2871,2873],{"className":97,"code":2872,"language":99,"meta":69,"style":69},"// nuxt.config.ts\nsitemap: {\n sources: ['/api/__sitemap__/urls'],\n excludeAppSources: ['/api/auth/**'],\n defaults: {\n changefreq: 'weekly',\n priority: 0.8,\n lastmod: new Date(),\n },\n urls: [\n { loc: '/', priority: 1.0, changefreq: 'daily' },\n { loc: '/about', priority: 0.9 },\n { loc: '/contact', priority: 0.7 },\n ],\n},\n",[59,2874,2875,2879,2886,2898,2910,2917,2929,2941,2956,2960,2967,2988,3002,3016,3020],{"__ignoreMap":69},[73,2876,2877],{"class":75,"line":76},[73,2878,107],{"class":106},[73,2880,2881,2884],{"class":75,"line":110},[73,2882,2883],{"class":79},"sitemap",[73,2885,139],{"class":116},[73,2887,2888,2891,2893,2896],{"class":75,"line":126},[73,2889,2890],{"class":79}," sources",[73,2892,117],{"class":116},[73,2894,2895],{"class":83},"'/api/__sitemap__/urls'",[73,2897,123],{"class":116},[73,2899,2900,2903,2905,2908],{"class":75,"line":133},[73,2901,2902],{"class":79}," excludeAppSources",[73,2904,117],{"class":116},[73,2906,2907],{"class":83},"'/api/auth/**'",[73,2909,123],{"class":116},[73,2911,2912,2915],{"class":75,"line":142},[73,2913,2914],{"class":79}," defaults",[73,2916,139],{"class":116},[73,2918,2919,2922,2924,2927],{"class":75,"line":157},[73,2920,2921],{"class":79}," changefreq",[73,2923,148],{"class":116},[73,2925,2926],{"class":83},"'weekly'",[73,2928,154],{"class":116},[73,2930,2931,2934,2936,2939],{"class":75,"line":165},[73,2932,2933],{"class":79}," priority",[73,2935,148],{"class":116},[73,2937,2938],{"class":87},"0.8",[73,2940,154],{"class":116},[73,2942,2943,2946,2948,2951,2954],{"class":75,"line":178},[73,2944,2945],{"class":79}," lastmod",[73,2947,148],{"class":116},[73,2949,2950],{"class":631},"new",[73,2952,2953],{"class":79}," Date",[73,2955,2117],{"class":116},[73,2957,2958],{"class":75,"line":191},[73,2959,307],{"class":116},[73,2961,2962,2965],{"class":75,"line":204},[73,2963,2964],{"class":79}," urls",[73,2966,262],{"class":116},[73,2968,2969,2972,2974,2977,2980,2983,2986],{"class":75,"line":217},[73,2970,2971],{"class":116}," { loc: ",[73,2973,424],{"class":83},[73,2975,2976],{"class":116},", priority: ",[73,2978,2979],{"class":87},"1.0",[73,2981,2982],{"class":116},", changefreq: ",[73,2984,2985],{"class":83},"'daily'",[73,2987,307],{"class":116},[73,2989,2990,2992,2995,2997,3000],{"class":75,"line":230},[73,2991,2971],{"class":116},[73,2993,2994],{"class":83},"'/about'",[73,2996,2976],{"class":116},[73,2998,2999],{"class":87},"0.9",[73,3001,307],{"class":116},[73,3003,3004,3006,3009,3011,3014],{"class":75,"line":243},[73,3005,2971],{"class":116},[73,3007,3008],{"class":83},"'/contact'",[73,3010,2976],{"class":116},[73,3012,3013],{"class":87},"0.7",[73,3015,307],{"class":116},[73,3017,3018],{"class":75,"line":256},[73,3019,400],{"class":116},[73,3021,3022],{"class":75,"line":265},[73,3023,562],{"class":116},[15,3025,3026,3027,3030],{},"For content-driven sites, the module automatically discovers routes from your ",[59,3028,3029],{},"pages/"," directory and Nuxt Content collections. You should not need to manually list every blog post.",[15,3032,3033,3034,3037],{},"Submit your sitemap to Google Search Console and Bing Webmaster Tools after launch. Verify it is accessible at ",[59,3035,3036],{},"/sitemap.xml"," and contains all your important pages.",[22,3039,3041],{"id":3040},"structured-data-with-schemaorg","Structured Data With Schema.org",[15,3043,3044],{},"Structured data is how you communicate machine-readable context to search engines. It enables rich results — star ratings in search results, FAQ dropdowns, article cards with author images.",[15,3046,3047],{},"For blog articles, use the Article schema:",[64,3049,3051],{"className":1101,"code":3050,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nuseSchemaOrg([\n defineArticle({\n headline: post.value.title,\n description: post.value.description,\n datePublished: post.value.date,\n dateModified: post.value.updatedAt ?? post.value.date,\n author: {\n '@type': 'Person',\n name: 'James Ross Jr.',\n url: 'https://jamesrossjr.com',\n },\n image: `https://jamesrossjr.com/og/${post.value.slug}.png`,\n }),\n])\n\u003C/script>\n",[59,3052,3053,3069,3077,3084,3089,3094,3099,3110,3115,3127,3136,3145,3149,3171,3176,3181],{"__ignoreMap":69},[73,3054,3055,3057,3059,3061,3063,3065,3067],{"class":75,"line":76},[73,3056,1115],{"class":116},[73,3058,1119],{"class":1118},[73,3060,1122],{"class":79},[73,3062,1125],{"class":79},[73,3064,991],{"class":116},[73,3066,1130],{"class":83},[73,3068,1133],{"class":116},[73,3070,3071,3074],{"class":75,"line":110},[73,3072,3073],{"class":79},"useSchemaOrg",[73,3075,3076],{"class":116},"([\n",[73,3078,3079,3082],{"class":75,"line":126},[73,3080,3081],{"class":79}," defineArticle",[73,3083,1336],{"class":116},[73,3085,3086],{"class":75,"line":133},[73,3087,3088],{"class":116}," headline: post.value.title,\n",[73,3090,3091],{"class":75,"line":142},[73,3092,3093],{"class":116}," description: post.value.description,\n",[73,3095,3096],{"class":75,"line":157},[73,3097,3098],{"class":116}," datePublished: post.value.date,\n",[73,3100,3101,3104,3107],{"class":75,"line":165},[73,3102,3103],{"class":116}," dateModified: post.value.updatedAt ",[73,3105,3106],{"class":631},"??",[73,3108,3109],{"class":116}," post.value.date,\n",[73,3111,3112],{"class":75,"line":178},[73,3113,3114],{"class":116}," author: {\n",[73,3116,3117,3120,3122,3125],{"class":75,"line":191},[73,3118,3119],{"class":83}," '@type'",[73,3121,148],{"class":116},[73,3123,3124],{"class":83},"'Person'",[73,3126,154],{"class":116},[73,3128,3129,3132,3134],{"class":75,"line":204},[73,3130,3131],{"class":116}," name: ",[73,3133,2829],{"class":83},[73,3135,154],{"class":116},[73,3137,3138,3141,3143],{"class":75,"line":217},[73,3139,3140],{"class":116}," url: ",[73,3142,2818],{"class":83},[73,3144,154],{"class":116},[73,3146,3147],{"class":75,"line":230},[73,3148,307],{"class":116},[73,3150,3151,3154,3157,3159,3161,3163,3165,3167,3169],{"class":75,"line":243},[73,3152,3153],{"class":116}," image: ",[73,3155,3156],{"class":83},"`https://jamesrossjr.com/og/${",[73,3158,2576],{"class":116},[73,3160,2274],{"class":83},[73,3162,2659],{"class":116},[73,3164,2274],{"class":83},[73,3166,2716],{"class":116},[73,3168,2719],{"class":83},[73,3170,154],{"class":116},[73,3172,3173],{"class":75,"line":256},[73,3174,3175],{"class":116}," }),\n",[73,3177,3178],{"class":75,"line":265},[73,3179,3180],{"class":116},"])\n",[73,3182,3183,3185,3187],{"class":75,"line":271},[73,3184,1159],{"class":116},[73,3186,1119],{"class":1118},[73,3188,1133],{"class":116},[15,3190,3191],{},"For a personal site, add Person schema to the homepage:",[64,3193,3195],{"className":97,"code":3194,"language":99,"meta":69,"style":69},"useSchemaOrg([\n definePerson({\n name: 'James Ross Jr.',\n description: 'Strategic Systems Architect & Enterprise Software Developer',\n url: 'https://jamesrossjr.com',\n sameAs: [\n 'https://linkedin.com/in/jamesrossjr',\n 'https://github.com/jamesrossjr',\n ],\n }),\n])\n",[59,3196,3197,3203,3210,3218,3226,3234,3239,3246,3253,3257,3261],{"__ignoreMap":69},[73,3198,3199,3201],{"class":75,"line":76},[73,3200,3073],{"class":79},[73,3202,3076],{"class":116},[73,3204,3205,3208],{"class":75,"line":110},[73,3206,3207],{"class":79}," definePerson",[73,3209,1336],{"class":116},[73,3211,3212,3214,3216],{"class":75,"line":126},[73,3213,3131],{"class":116},[73,3215,2829],{"class":83},[73,3217,154],{"class":116},[73,3219,3220,3222,3224],{"class":75,"line":133},[73,3221,2467],{"class":116},[73,3223,2840],{"class":83},[73,3225,154],{"class":116},[73,3227,3228,3230,3232],{"class":75,"line":142},[73,3229,3140],{"class":116},[73,3231,2818],{"class":83},[73,3233,154],{"class":116},[73,3235,3236],{"class":75,"line":157},[73,3237,3238],{"class":116}," sameAs: [\n",[73,3240,3241,3244],{"class":75,"line":165},[73,3242,3243],{"class":83}," 'https://linkedin.com/in/jamesrossjr'",[73,3245,154],{"class":116},[73,3247,3248,3251],{"class":75,"line":178},[73,3249,3250],{"class":83}," 'https://github.com/jamesrossjr'",[73,3252,154],{"class":116},[73,3254,3255],{"class":75,"line":191},[73,3256,400],{"class":116},[73,3258,3259],{"class":75,"line":204},[73,3260,3175],{"class":116},[73,3262,3263],{"class":75,"line":217},[73,3264,3180],{"class":116},[15,3266,3267],{},"Validate your structured data with Google's Rich Results Test before considering it done.",[22,3269,3271],{"id":3270},"core-web-vitals-the-rankings-signal","Core Web Vitals: The Rankings Signal",[15,3273,3274],{},"Since Google's page experience update, Core Web Vitals are a direct ranking signal. The three metrics are Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interaction to Next Paint (INP).",[15,3276,3277,3280],{},[32,3278,3279],{},"LCP"," measures how quickly the main content loads. The target is under 2.5 seconds. Nuxt SSR helps here because the server sends HTML immediately. But you also need:",[2304,3282,3283,3286,3296],{},[2307,3284,3285],{},"Images served in modern formats (WebP, AVIF)",[2307,3287,3288,3291,3292,3295],{},[59,3289,3290],{},"loading=\"eager\""," and ",[59,3293,3294],{},"fetchpriority=\"high\""," on your above-the-fold image",[2307,3297,3298],{},"A CDN serving assets close to users",[64,3300,3302],{"className":1101,"code":3301,"language":1103,"meta":69,"style":69},"\u003C!-- The main hero image should be eager, not lazy -->\n\u003CNuxtImg\n src=\"/hero.jpg\"\n alt=\"Hero image description\"\n width=\"1200\"\n height=\"630\"\n loading=\"eager\"\n fetchpriority=\"high\"\n format=\"webp\"\n/>\n",[59,3303,3304,3309,3316,3326,3336,3346,3356,3366,3376,3386],{"__ignoreMap":69},[73,3305,3306],{"class":75,"line":76},[73,3307,3308],{"class":106},"\u003C!-- The main hero image should be eager, not lazy -->\n",[73,3310,3311,3313],{"class":75,"line":110},[73,3312,1115],{"class":116},[73,3314,3315],{"class":1118},"NuxtImg\n",[73,3317,3318,3321,3323],{"class":75,"line":126},[73,3319,3320],{"class":79}," src",[73,3322,991],{"class":116},[73,3324,3325],{"class":83},"\"/hero.jpg\"\n",[73,3327,3328,3331,3333],{"class":75,"line":133},[73,3329,3330],{"class":79}," alt",[73,3332,991],{"class":116},[73,3334,3335],{"class":83},"\"Hero image description\"\n",[73,3337,3338,3341,3343],{"class":75,"line":142},[73,3339,3340],{"class":79}," width",[73,3342,991],{"class":116},[73,3344,3345],{"class":83},"\"1200\"\n",[73,3347,3348,3351,3353],{"class":75,"line":157},[73,3349,3350],{"class":79}," height",[73,3352,991],{"class":116},[73,3354,3355],{"class":83},"\"630\"\n",[73,3357,3358,3361,3363],{"class":75,"line":165},[73,3359,3360],{"class":79}," loading",[73,3362,991],{"class":116},[73,3364,3365],{"class":83},"\"eager\"\n",[73,3367,3368,3371,3373],{"class":75,"line":178},[73,3369,3370],{"class":79}," fetchpriority",[73,3372,991],{"class":116},[73,3374,3375],{"class":83},"\"high\"\n",[73,3377,3378,3381,3383],{"class":75,"line":191},[73,3379,3380],{"class":79}," format",[73,3382,991],{"class":116},[73,3384,3385],{"class":83},"\"webp\"\n",[73,3387,3388],{"class":75,"line":204},[73,3389,3390],{"class":116},"/>\n",[15,3392,3393,3396,3397,3291,3400,3403],{},[32,3394,3395],{},"CLS"," measures unexpected layout shifts. The most common cause is images without declared dimensions. Always provide ",[59,3398,3399],{},"width",[59,3401,3402],{},"height"," attributes on images. Use skeleton loaders for async content to reserve space.",[64,3405,3407],{"className":1101,"code":3406,"language":1103,"meta":69,"style":69},"\u003C!-- Without width/height, the image causes layout shift -->\n\u003Cimg src=\"/product.jpg\" width=\"400\" height=\"300\" alt=\"Product image\" />\n\n\u003C!-- For async content, reserve space with a skeleton -->\n\u003Cdiv v-if=\"loading\" class=\"h-64 bg-gray-100 animate-pulse rounded\" />\n\u003CProductCard v-else :product=\"product\" />\n",[59,3408,3409,3414,3452,3456,3461,3488],{"__ignoreMap":69},[73,3410,3411],{"class":75,"line":76},[73,3412,3413],{"class":106},"\u003C!-- Without width/height, the image causes layout shift -->\n",[73,3415,3416,3418,3421,3423,3425,3428,3430,3432,3435,3437,3439,3442,3444,3446,3449],{"class":75,"line":110},[73,3417,1115],{"class":116},[73,3419,3420],{"class":1118},"img",[73,3422,3320],{"class":79},[73,3424,991],{"class":116},[73,3426,3427],{"class":83},"\"/product.jpg\"",[73,3429,3340],{"class":79},[73,3431,991],{"class":116},[73,3433,3434],{"class":83},"\"400\"",[73,3436,3350],{"class":79},[73,3438,991],{"class":116},[73,3440,3441],{"class":83},"\"300\"",[73,3443,3330],{"class":79},[73,3445,991],{"class":116},[73,3447,3448],{"class":83},"\"Product image\"",[73,3450,3451],{"class":116}," />\n",[73,3453,3454],{"class":75,"line":126},[73,3455,130],{"emptyLinePlaceholder":129},[73,3457,3458],{"class":75,"line":133},[73,3459,3460],{"class":106},"\u003C!-- For async content, reserve space with a skeleton -->\n",[73,3462,3463,3465,3467,3469,3471,3474,3477,3479,3481,3483,3486],{"class":75,"line":142},[73,3464,1115],{"class":116},[73,3466,1258],{"class":1118},[73,3468,1205],{"class":631},[73,3470,991],{"class":116},[73,3472,3473],{"class":83},"\"",[73,3475,3476],{"class":116},"loading",[73,3478,3473],{"class":83},[73,3480,1215],{"class":79},[73,3482,991],{"class":116},[73,3484,3485],{"class":83},"\"h-64 bg-gray-100 animate-pulse rounded\"",[73,3487,3451],{"class":116},[73,3489,3490,3492,3495,3498,3501,3504,3506,3508,3510,3512],{"class":75,"line":157},[73,3491,1115],{"class":116},[73,3493,3494],{"class":1118},"ProductCard",[73,3496,3497],{"class":631}," v-else",[73,3499,3500],{"class":116}," :",[73,3502,3503],{"class":79},"product",[73,3505,991],{"class":116},[73,3507,3473],{"class":83},[73,3509,3503],{"class":116},[73,3511,3473],{"class":83},[73,3513,3451],{"class":116},[15,3515,3516,3519],{},[32,3517,3518],{},"INP"," measures responsiveness to user input. Heavy JavaScript on the main thread is the main culprit. Keep your JavaScript bundles lean, defer non-critical scripts, and avoid long-running synchronous operations.",[22,3521,3523],{"id":3522},"robotstxt-and-crawl-control","Robots.txt and Crawl Control",[15,3525,3526],{},"A proper robots.txt file tells crawlers what to index:",[64,3528,3530],{"className":97,"code":3529,"language":99,"meta":69,"style":69},"// nuxt.config.ts\nrobots: {\n disallow: ['/api/', '/admin/', '/_nuxt/'],\n allow: '/',\n},\n",[59,3531,3532,3536,3543,3565,3576],{"__ignoreMap":69},[73,3533,3534],{"class":75,"line":76},[73,3535,107],{"class":106},[73,3537,3538,3541],{"class":75,"line":110},[73,3539,3540],{"class":79},"robots",[73,3542,139],{"class":116},[73,3544,3545,3548,3550,3553,3555,3558,3560,3563],{"class":75,"line":126},[73,3546,3547],{"class":79}," disallow",[73,3549,117],{"class":116},[73,3551,3552],{"class":83},"'/api/'",[73,3554,710],{"class":116},[73,3556,3557],{"class":83},"'/admin/'",[73,3559,710],{"class":116},[73,3561,3562],{"class":83},"'/_nuxt/'",[73,3564,123],{"class":116},[73,3566,3567,3570,3572,3574],{"class":75,"line":133},[73,3568,3569],{"class":79}," allow",[73,3571,148],{"class":116},[73,3573,424],{"class":83},[73,3575,154],{"class":116},[73,3577,3578],{"class":75,"line":142},[73,3579,562],{"class":116},[15,3581,3582],{},"Block routes you do not want indexed: API endpoints, admin panels, staging environments, internal search result pages with URL parameters.",[15,3584,3585],{},"For pages that should exist but not be indexed (thank-you pages, confirmation pages, paginated pages after page 2), use the robots meta tag:",[64,3587,3589],{"className":1101,"code":3588,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nuseSeoMeta({\n robots: 'noindex, follow',\n})\n\u003C/script>\n",[59,3590,3591,3607,3613,3623,3627],{"__ignoreMap":69},[73,3592,3593,3595,3597,3599,3601,3603,3605],{"class":75,"line":76},[73,3594,1115],{"class":116},[73,3596,1119],{"class":1118},[73,3598,1122],{"class":79},[73,3600,1125],{"class":79},[73,3602,991],{"class":116},[73,3604,1130],{"class":83},[73,3606,1133],{"class":116},[73,3608,3609,3611],{"class":75,"line":110},[73,3610,2424],{"class":79},[73,3612,1336],{"class":116},[73,3614,3615,3618,3621],{"class":75,"line":126},[73,3616,3617],{"class":116}," robots: ",[73,3619,3620],{"class":83},"'noindex, follow'",[73,3622,154],{"class":116},[73,3624,3625],{"class":75,"line":133},[73,3626,1379],{"class":116},[73,3628,3629,3631,3633],{"class":75,"line":142},[73,3630,1159],{"class":116},[73,3632,1119],{"class":1118},[73,3634,1133],{"class":116},[22,3636,3638],{"id":3637},"canonical-urls-preventing-duplicate-content","Canonical URLs: Preventing Duplicate Content",[15,3640,3641],{},"Duplicate content dilutes your SEO signal. Canonical tags tell search engines which version of a page is the authoritative one:",[64,3643,3645],{"className":97,"code":3644,"language":99,"meta":69,"style":69},"// nuxt.config.ts\n// @nuxtjs/seo handles this with redirectToCanonicalSiteUrl\n// But you can also set it manually per page:\n\nUseSeoMeta({\n canonical: `https://jamesrossjr.com${route.path}`,\n})\n",[59,3646,3647,3651,3656,3661,3665,3671,3690],{"__ignoreMap":69},[73,3648,3649],{"class":75,"line":76},[73,3650,107],{"class":106},[73,3652,3653],{"class":75,"line":110},[73,3654,3655],{"class":106},"// @nuxtjs/seo handles this with redirectToCanonicalSiteUrl\n",[73,3657,3658],{"class":75,"line":126},[73,3659,3660],{"class":106},"// But you can also set it manually per page:\n",[73,3662,3663],{"class":75,"line":133},[73,3664,130],{"emptyLinePlaceholder":129},[73,3666,3667,3669],{"class":75,"line":142},[73,3668,2637],{"class":79},[73,3670,1336],{"class":116},[73,3672,3673,3676,3679,3682,3684,3686,3688],{"class":75,"line":157},[73,3674,3675],{"class":116}," canonical: ",[73,3677,3678],{"class":83},"`https://jamesrossjr.com${",[73,3680,3681],{"class":116},"route",[73,3683,2274],{"class":83},[73,3685,2611],{"class":116},[73,3687,1367],{"class":83},[73,3689,154],{"class":116},[73,3691,3692],{"class":75,"line":165},[73,3693,1379],{"class":116},[15,3695,3696,3697,3700],{},"Common duplication sources: HTTP vs HTTPS, www vs non-www, trailing slash vs no trailing slash. Configure your hosting to redirect one canonical form and set ",[59,3698,3699],{},"redirectToCanonicalSiteUrl: true"," in your SEO module configuration.",[22,3702,3704],{"id":3703},"internationalized-seo","Internationalized SEO",[15,3706,3707],{},"If your site targets multiple languages, hreflang tags are essential. They tell search engines which language version to show to which users:",[64,3709,3711],{"className":1101,"code":3710,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nuseHead({\n link: [\n { rel: 'alternate', hreflang: 'en', href: 'https://yourdomain.com/en/page' },\n { rel: 'alternate', hreflang: 'es', href: 'https://yourdomain.com/es/page' },\n { rel: 'alternate', hreflang: 'x-default', href: 'https://yourdomain.com/en/page' },\n ],\n})\n\u003C/script>\n",[59,3712,3713,3729,3736,3741,3762,3780,3797,3801,3805],{"__ignoreMap":69},[73,3714,3715,3717,3719,3721,3723,3725,3727],{"class":75,"line":76},[73,3716,1115],{"class":116},[73,3718,1119],{"class":1118},[73,3720,1122],{"class":79},[73,3722,1125],{"class":79},[73,3724,991],{"class":116},[73,3726,1130],{"class":83},[73,3728,1133],{"class":116},[73,3730,3731,3734],{"class":75,"line":110},[73,3732,3733],{"class":79},"useHead",[73,3735,1336],{"class":116},[73,3737,3738],{"class":75,"line":126},[73,3739,3740],{"class":116}," link: [\n",[73,3742,3743,3746,3749,3752,3754,3757,3760],{"class":75,"line":133},[73,3744,3745],{"class":116}," { rel: ",[73,3747,3748],{"class":83},"'alternate'",[73,3750,3751],{"class":116},", hreflang: ",[73,3753,2852],{"class":83},[73,3755,3756],{"class":116},", href: ",[73,3758,3759],{"class":83},"'https://yourdomain.com/en/page'",[73,3761,307],{"class":116},[73,3763,3764,3766,3768,3770,3773,3775,3778],{"class":75,"line":142},[73,3765,3745],{"class":116},[73,3767,3748],{"class":83},[73,3769,3751],{"class":116},[73,3771,3772],{"class":83},"'es'",[73,3774,3756],{"class":116},[73,3776,3777],{"class":83},"'https://yourdomain.com/es/page'",[73,3779,307],{"class":116},[73,3781,3782,3784,3786,3788,3791,3793,3795],{"class":75,"line":157},[73,3783,3745],{"class":116},[73,3785,3748],{"class":83},[73,3787,3751],{"class":116},[73,3789,3790],{"class":83},"'x-default'",[73,3792,3756],{"class":116},[73,3794,3759],{"class":83},[73,3796,307],{"class":116},[73,3798,3799],{"class":75,"line":165},[73,3800,400],{"class":116},[73,3802,3803],{"class":75,"line":178},[73,3804,1379],{"class":116},[73,3806,3807,3809,3811],{"class":75,"line":191},[73,3808,1159],{"class":116},[73,3810,1119],{"class":1118},[73,3812,1133],{"class":116},[15,3814,57,3815,3818],{},[59,3816,3817],{},"@nuxtjs/i18n"," module handles this automatically when configured correctly.",[22,3820,3822],{"id":3821},"measuring-and-monitoring","Measuring and Monitoring",[15,3824,3825],{},"Set up Google Search Console on day one, not after you are already concerned about rankings. The data it provides about impressions, clicks, and crawl errors is irreplaceable.",[15,3827,3828],{},"Track Core Web Vitals in production with the web-vitals library. CrUX (Chrome User Experience Report) data in Search Console shows real-user metrics, not just lab data. They often tell different stories.",[15,3830,3831],{},"Check your structured data monthly with Google's Rich Results Test. A module update or template change can accidentally break schema output without any visible error.",[15,3833,3834],{},"SEO is a discipline, not a task you complete. The technical foundation — correct meta tags, valid structured data, fast Core Web Vitals, clean sitemaps — needs to be in place before content strategy can compound. Get the foundation right first.",[2326,3836],{},[15,3838,3839,3840,2274],{},"Want a technical SEO audit of your Nuxt application or help setting up the right SEO infrastructure from the start? Book a call: ",[2332,3841,2337],{"href":2334,"rel":3842},[2336],[2326,3844],{},[22,3846,2343],{"id":2342},[2304,3848,3849,3853,3857,3861],{},[2307,3850,3851],{},[2332,3852,2369],{"href":2368},[2307,3854,3855],{},[2332,3856,2351],{"href":2350},[2307,3858,3859],{},[2332,3860,7],{"href":2395},[2307,3862,3863],{},[2332,3864,3866],{"href":3865},"/blog/nuxt-cloudflare-deployment","Deploying Nuxt to Cloudflare Pages: The Complete Walkthrough",[2371,3868,3869],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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 pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":69,"searchDepth":126,"depth":126,"links":3871},[3872,3873,3874,3875,3876,3877,3878,3879,3880,3881],{"id":2417,"depth":110,"text":2418},{"id":2739,"depth":110,"text":2740},{"id":2861,"depth":110,"text":2862},{"id":3040,"depth":110,"text":3041},{"id":3270,"depth":110,"text":3271},{"id":3522,"depth":110,"text":3523},{"id":3637,"depth":110,"text":3638},{"id":3703,"depth":110,"text":3704},{"id":3821,"depth":110,"text":3822},{"id":2342,"depth":110,"text":2343},"A complete technical SEO guide for Nuxt applications — meta tags, structured data, sitemaps, Core Web Vitals, and the @nuxtjs/seo module that handles it all.",[3884,3885],"Nuxt SEO","Nuxt SEO optimization",{},"/blog/nuxt-seo-optimization",{"title":2405,"description":3882},"blog/nuxt-seo-optimization",[2399,3891,3892],"SEO","Web Performance","vCDFpk7miXxwHr_jLK-ufcjw-qnE-tARP823ioH7af8",{"id":3895,"title":3896,"author":3897,"body":3898,"category":4386,"date":2386,"description":4387,"extension":2388,"featured":2389,"image":2390,"keywords":4388,"meta":4391,"navigation":129,"path":4392,"readTime":165,"seo":4393,"stem":4394,"tags":4395,"__hash__":4398},"blog/blog/nuxt-ssr-guide.md","Server-Side Rendering With Nuxt: When SSR Beats SPA",{"name":9,"bio":10},{"type":12,"value":3899,"toc":4376},[3900,3903,3906,3909,3913,3916,3919,3946,3949,3953,3959,3965,3971,3974,3978,3984,3990,3996,3999,4003,4006,4087,4090,4096,4100,4103,4109,4115,4121,4125,4128,4131,4216,4227,4247,4289,4295,4299,4302,4308,4314,4320,4326,4332,4335,4337,4343,4345,4347,4373],[15,3901,3902],{},"One of the most consequential architectural decisions on a web project is choosing your rendering strategy. Get it wrong and you are fighting your framework on every performance problem. Get it right and performance falls out naturally from your architecture.",[15,3904,3905],{},"Nuxt gives you four rendering modes: server-side rendering (SSR), static site generation (SSG), incremental static regeneration (ISR), and client-side rendering (SPA). Each has a genuine use case. The mistake is defaulting to SSR for everything or, worse, defaulting to SPA because that is what you are most familiar with.",[15,3907,3908],{},"I am going to walk through each mode, when to use it, and the real trade-offs — including some data from production applications.",[22,3910,3912],{"id":3911},"understanding-what-ssr-actually-does","Understanding What SSR Actually Does",[15,3914,3915],{},"When a user requests a page from an SSR application, your server runs your Vue components, generates the HTML, sends it to the browser, then the browser downloads JavaScript and \"hydrates\" the page so it becomes interactive. The user sees content immediately on first load instead of staring at a blank screen while JavaScript downloads and runs.",[15,3917,3918],{},"The full sequence:",[3920,3921,3922,3928,3931,3934,3937,3940,3943],"ol",{},[2307,3923,3924,3925],{},"Browser requests ",[59,3926,3927],{},"/products/123",[2307,3929,3930],{},"Server renders the Vue component with real data",[2307,3932,3933],{},"Server sends complete HTML",[2307,3935,3936],{},"Browser renders HTML immediately",[2307,3938,3939],{},"Browser downloads JavaScript",[2307,3941,3942],{},"Vue hydrates the existing HTML",[2307,3944,3945],{},"Page is now interactive",[15,3947,3948],{},"The SPA equivalent of step 3 sends an empty HTML shell. The user sees nothing until steps 5 and 6 complete. On a fast connection with a small app, the difference is negligible. On a mobile connection with a large app, it is the difference between content appearing in 0.8 seconds versus 4 seconds.",[22,3950,3952],{"id":3951},"when-ssr-genuinely-wins","When SSR Genuinely Wins",[15,3954,3955,3958],{},[32,3956,3957],{},"E-commerce product pages."," Organic search traffic converts. Bots cannot reliably execute JavaScript. If your product pages need to rank and you have dynamic inventory, pricing, and product details, SSR is the answer. The SEO benefit alone justifies the server costs for most e-commerce businesses.",[15,3960,3961,3964],{},[32,3962,3963],{},"News and editorial content that is personalized."," A news site might show different content based on location or subscription status. SSG cannot handle personalization. SPA shows the wrong content until JavaScript loads. SSR can make the decision on the server and send the right HTML the first time.",[15,3966,3967,3970],{},[32,3968,3969],{},"Authenticated dashboards with data that changes frequently."," If your dashboard data changes every few minutes, static generation is not useful. SSR lets you render with fresh data on every request without the SPA's blank-screen problem.",[15,3972,3973],{},"I rebuilt a SaaS dashboard from an SPA to SSR last year. First meaningful paint improved by 1.8 seconds on median mobile hardware. Support tickets about \"the app taking forever to load\" dropped by 70% in the month after launch. Those numbers make the business case for SSR better than any benchmark.",[22,3975,3977],{"id":3976},"when-ssg-beats-ssr","When SSG Beats SSR",[15,3979,3980,3983],{},[32,3981,3982],{},"Marketing sites and landing pages."," The content rarely changes. There are no authenticated states. You want the fastest possible performance. Static generation wins every time. Pre-built HTML served from a CDN is faster than any server can respond, because there is no server involved.",[15,3985,3986,3989],{},[32,3987,3988],{},"Documentation sites."," Content updates happen through Git merges. You want global performance. Nuxt generates hundreds of pages at build time, Cloudflare Pages serves them from the edge, and your users get sub-100ms response times globally.",[15,3991,3992,3995],{},[32,3993,3994],{},"Blogs."," Unless you have thousands of articles and need incremental builds, just generate everything statically. The build takes longer but the runtime experience is unmatched.",[15,3997,3998],{},"The trade-off with SSG is build time and rebuild frequency. Adding one blog post to a site with 2,000 pages means rebuilding all 2,000 pages. For most sites this takes a few minutes and happens rarely enough that it is not a problem. For sites where content editors expect new pages to appear in seconds, SSG is the wrong choice.",[22,4000,4002],{"id":4001},"incremental-static-regeneration-the-middle-ground","Incremental Static Regeneration: The Middle Ground",[15,4004,4005],{},"ISR (Nuxt calls it \"hybrid rendering\") lets you specify a revalidation time per route. The page is generated statically but refreshed in the background at your specified interval:",[64,4007,4009],{"className":97,"code":4008,"language":99,"meta":69,"style":69},"// nuxt.config.ts\nrouteRules: {\n '/products/**': { swr: 3600 }, // Regenerate hourly\n '/blog/**': { prerender: true }, // Static, rebuild on deploy\n '/dashboard/**': { ssr: true }, // Always SSR\n '/api/**': { cors: true }, // API routes, no caching\n}\n",[59,4010,4011,4015,4022,4038,4053,4068,4083],{"__ignoreMap":69},[73,4012,4013],{"class":75,"line":76},[73,4014,107],{"class":106},[73,4016,4017,4020],{"class":75,"line":110},[73,4018,4019],{"class":79},"routeRules",[73,4021,139],{"class":116},[73,4023,4024,4027,4030,4032,4035],{"class":75,"line":126},[73,4025,4026],{"class":83}," '/products/**'",[73,4028,4029],{"class":116},": { swr: ",[73,4031,488],{"class":87},[73,4033,4034],{"class":116}," }, ",[73,4036,4037],{"class":106},"// Regenerate hourly\n",[73,4039,4040,4043,4046,4048,4050],{"class":75,"line":133},[73,4041,4042],{"class":83}," '/blog/**'",[73,4044,4045],{"class":116},": { prerender: ",[73,4047,437],{"class":87},[73,4049,4034],{"class":116},[73,4051,4052],{"class":106},"// Static, rebuild on deploy\n",[73,4054,4055,4058,4061,4063,4065],{"class":75,"line":142},[73,4056,4057],{"class":83}," '/dashboard/**'",[73,4059,4060],{"class":116},": { ssr: ",[73,4062,437],{"class":87},[73,4064,4034],{"class":116},[73,4066,4067],{"class":106},"// Always SSR\n",[73,4069,4070,4073,4076,4078,4080],{"class":75,"line":157},[73,4071,4072],{"class":83}," '/api/**'",[73,4074,4075],{"class":116},": { cors: ",[73,4077,437],{"class":87},[73,4079,4034],{"class":116},[73,4081,4082],{"class":106},"// API routes, no caching\n",[73,4084,4085],{"class":75,"line":165},[73,4086,1098],{"class":116},[15,4088,4089],{},"This is powerful for content that changes occasionally but not every request. Product pages can regenerate hourly. A user sees cached HTML that is at most one hour old — much better than SPA, close to SSR quality, at a fraction of the server cost.",[15,4091,57,4092,4095],{},[59,4093,4094],{},"stale-while-revalidate"," pattern means users never wait for regeneration. They get the cached version immediately. The new version generates in the background and is available for the next request.",[22,4097,4099],{"id":4098},"spa-mode-when-it-is-actually-right","SPA Mode: When It Is Actually Right",[15,4101,4102],{},"SPA is not a worst-case fallback — it is the right choice for specific application types.",[15,4104,4105,4108],{},[32,4106,4107],{},"Authenticated apps with no SEO requirements."," If your app requires login to access any page, search engines cannot index it anyway. SSR adds server cost without SEO benefit. Build an SPA.",[15,4110,4111,4114],{},[32,4112,4113],{},"Highly interactive applications."," Rich text editors, design tools, spreadsheet-like interfaces — these are application states that make no sense to server-render. They need the full JavaScript environment immediately and do not have meaningful SEO surface area.",[15,4116,4117,4120],{},[32,4118,4119],{},"Internal tooling."," If your users are on a corporate network with fast connections and you have no external traffic, the SPA performance trade-off is acceptable. Do not add server infrastructure complexity for an internal dashboard used by 50 people.",[22,4122,4124],{"id":4123},"hydration-the-hidden-footgun","Hydration: The Hidden Footgun",[15,4126,4127],{},"SSR introduces a class of bugs that SPA developers have never encountered: hydration mismatches. When the server renders HTML that does not match what the client would render, Vue logs a hydration warning and re-renders the component client-side. In severe cases this causes layout flashes or broken component state.",[15,4129,4130],{},"Common causes:",[64,4132,4134],{"className":1101,"code":4133,"language":1103,"meta":69,"style":69},"\u003C!-- BAD: Math.random() produces different values on server and client -->\n\u003Ctemplate>\n \u003Cdiv :id=\"`item-${Math.random()}`\">...\u003C/div>\n\u003C/template>\n\n\u003C!-- BAD: new Date() produces different timestamps -->\n\u003Ctemplate>\n \u003Cspan>{{ new Date().toLocaleDateString() }}\u003C/span>\n\u003C/template>\n",[59,4135,4136,4141,4149,4170,4178,4182,4187,4195,4208],{"__ignoreMap":69},[73,4137,4138],{"class":75,"line":76},[73,4139,4140],{"class":106},"\u003C!-- BAD: Math.random() produces different values on server and client -->\n",[73,4142,4143,4145,4147],{"class":75,"line":110},[73,4144,1115],{"class":116},[73,4146,1174],{"class":1118},[73,4148,1133],{"class":116},[73,4150,4151,4153,4155,4158,4160,4163,4166,4168],{"class":75,"line":126},[73,4152,1181],{"class":116},[73,4154,1258],{"class":1118},[73,4156,4157],{"class":79}," :id",[73,4159,991],{"class":116},[73,4161,4162],{"class":83},"\"`item-${Math.random()}`\"",[73,4164,4165],{"class":116},">...\u003C/",[73,4167,1258],{"class":1118},[73,4169,1133],{"class":116},[73,4171,4172,4174,4176],{"class":75,"line":133},[73,4173,1159],{"class":116},[73,4175,1174],{"class":1118},[73,4177,1133],{"class":116},[73,4179,4180],{"class":75,"line":142},[73,4181,130],{"emptyLinePlaceholder":129},[73,4183,4184],{"class":75,"line":157},[73,4185,4186],{"class":106},"\u003C!-- BAD: new Date() produces different timestamps -->\n",[73,4188,4189,4191,4193],{"class":75,"line":165},[73,4190,1115],{"class":116},[73,4192,1174],{"class":1118},[73,4194,1133],{"class":116},[73,4196,4197,4199,4201,4204,4206],{"class":75,"line":178},[73,4198,1181],{"class":116},[73,4200,73],{"class":1118},[73,4202,4203],{"class":116},">{{ new Date().toLocaleDateString() }}\u003C/",[73,4205,73],{"class":1118},[73,4207,1133],{"class":116},[73,4209,4210,4212,4214],{"class":75,"line":191},[73,4211,1159],{"class":116},[73,4213,1174],{"class":1118},[73,4215,1133],{"class":116},[15,4217,4218,4219,4222,4223,4226],{},"The fix for random IDs is to use Vue's ",[59,4220,4221],{},"useId()"," composable. The fix for dates is to format them consistently, or use ",[59,4224,4225],{},"\u003CClientOnly>"," to defer rendering to the client.",[15,4228,4229,4230,710,4233,710,4236,4239,4240,4243,4244,4246],{},"Anything that depends on browser APIs (",[59,4231,4232],{},"window",[59,4234,4235],{},"localStorage",[59,4237,4238],{},"navigator",") will break on the server. Wrap these with ",[59,4241,4242],{},"if (process.client)"," checks or the ",[59,4245,4225],{}," component:",[64,4248,4250],{"className":1101,"code":4249,"language":1103,"meta":69,"style":69},"\u003CClientOnly>\n \u003CMapComponent />\n \u003Ctemplate #fallback>\n \u003Cdiv class=\"h-64 bg-gray-100 animate-pulse rounded\" />\n \u003C/template>\n\u003C/ClientOnly>\n",[59,4251,4252,4261,4266,4271,4276,4281],{"__ignoreMap":69},[73,4253,4254,4256,4259],{"class":75,"line":76},[73,4255,1115],{"class":116},[73,4257,4258],{"class":1118},"ClientOnly",[73,4260,1133],{"class":116},[73,4262,4263],{"class":75,"line":110},[73,4264,4265],{"class":116}," \u003CMapComponent />\n",[73,4267,4268],{"class":75,"line":126},[73,4269,4270],{"class":116}," \u003Ctemplate #fallback>\n",[73,4272,4273],{"class":75,"line":133},[73,4274,4275],{"class":116}," \u003Cdiv class=\"h-64 bg-gray-100 animate-pulse rounded\" />\n",[73,4277,4278],{"class":75,"line":142},[73,4279,4280],{"class":116}," \u003C/template>\n",[73,4282,4283,4285,4287],{"class":75,"line":157},[73,4284,1159],{"class":116},[73,4286,4258],{"class":1118},[73,4288,1133],{"class":116},[15,4290,57,4291,4294],{},[59,4292,4293],{},"#fallback"," slot renders on the server and during hydration — use it to show a skeleton or placeholder that matches the component's dimensions to prevent layout shift.",[22,4296,4298],{"id":4297},"performance-metrics-that-guide-the-decision","Performance Metrics That Guide the Decision",[15,4300,4301],{},"Here are the numbers I look at when making rendering strategy recommendations:",[15,4303,4304,4307],{},[32,4305,4306],{},"Time to First Byte (TTFB):"," SSG wins (CDN edge delivery), ISR is close (cached responses), SSR is slowest (server must run).",[15,4309,4310,4313],{},[32,4311,4312],{},"First Contentful Paint (FCP):"," SSR and SSG roughly equal (both send HTML). SPA is slowest.",[15,4315,4316,4319],{},[32,4317,4318],{},"Largest Contentful Paint (LCP):"," SSG usually wins. SSR depends on server response time. SPA depends on API response time after JS loads.",[15,4321,4322,4325],{},[32,4323,4324],{},"Server costs:"," SSG lowest (static files, no compute), ISR middle (compute only on regeneration), SSR highest (compute every request).",[15,4327,4328,4329,4331],{},"For most projects, the right answer is hybrid: static generation for marketing and content, SSR for authenticated routes that need fresh data, and SPA for highly interactive tools. Nuxt's ",[59,4330,4019],{}," makes this configuration straightforward.",[15,4333,4334],{},"Do not default to SSR without thinking through the trade-offs. And do not default to SPA because it is simpler to reason about. The rendering strategy should follow from your content, your users, and your SEO requirements.",[2326,4336],{},[15,4338,4339,4340,2274],{},"If you are designing a new Nuxt application and want help choosing the right rendering strategy for your specific requirements, book a call: ",[2332,4341,2337],{"href":2334,"rel":4342},[2336],[2326,4344],{},[22,4346,2343],{"id":2342},[2304,4348,4349,4355,4361,4367],{},[2307,4350,4351],{},[2332,4352,4354],{"href":4353},"/blog/why-i-chose-nuxt-over-nextjs","Why I Chose Nuxt Over Next.js for My Portfolio",[2307,4356,4357],{},[2332,4358,4360],{"href":4359},"/blog/api-design-best-practices","API Design Best Practices That Survive Production",[2307,4362,4363],{},[2332,4364,4366],{"href":4365},"/blog/api-gateway-patterns","API Gateway Patterns: More Than Just a Reverse Proxy",[2307,4368,4369],{},[2332,4370,4372],{"href":4371},"/blog/architecture-decision-records","Architecture Decision Records: Why You Need Them and How to Write Them",[2371,4374,4375],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":69,"searchDepth":126,"depth":126,"links":4377},[4378,4379,4380,4381,4382,4383,4384,4385],{"id":3911,"depth":110,"text":3912},{"id":3951,"depth":110,"text":3952},{"id":3976,"depth":110,"text":3977},{"id":4001,"depth":110,"text":4002},{"id":4098,"depth":110,"text":4099},{"id":4123,"depth":110,"text":4124},{"id":4297,"depth":110,"text":4298},{"id":2342,"depth":110,"text":2343},"Architecture","A practical breakdown of when to use SSR, SSG, ISR, or SPA in Nuxt — with real performance data and architectural trade-offs from production deployments.",[4389,4390],"Nuxt SSR","server-side rendering Nuxt",{},"/blog/nuxt-ssr-guide",{"title":3896,"description":4387},"blog/nuxt-ssr-guide",[2399,4396,4397],"SSR","Performance","jHv8P8af2m9N48DfAlsIsHNMAADaEqdTADC-pFwZ9BE",{"id":4400,"title":4401,"author":4402,"body":4403,"category":2385,"date":2386,"description":7668,"extension":2388,"featured":2389,"image":2390,"keywords":7669,"meta":7672,"navigation":129,"path":7673,"readTime":165,"seo":7674,"stem":7675,"tags":7676,"__hash__":7679},"blog/blog/nuxt-testing-vitest.md","Testing Nuxt Applications With Vitest: A Practical Setup",{"name":9,"bio":10},{"type":12,"value":4404,"toc":7657},[4405,4408,4411,4415,4418,4424,4430,4436,4439,4443,4471,4477,4649,4655,4728,4732,4739,4874,5271,5278,5579,5583,5590,6068,6072,6075,6506,6515,6646,6650,6653,6908,6912,6915,7095,7449,7453,7456,7619,7622,7624,7630,7632,7634,7654],[15,4406,4407],{},"Testing Nuxt applications has a reputation for being complicated. You have SSR, auto-imports, Pinia stores, Nitro server routes, and Vue components all in the same codebase, and each one has different testing requirements. The good news is that the tooling has matured considerably, and with the right setup you can have comprehensive test coverage without fighting your framework constantly.",[15,4409,4410],{},"This article walks through the complete testing stack I use on production Nuxt applications: unit tests with Vitest, component tests with Vue Testing Library, and E2E tests with Playwright.",[22,4412,4414],{"id":4413},"the-testing-stack","The Testing Stack",[15,4416,4417],{},"I use three layers of tests:",[15,4419,4420,4423],{},[32,4421,4422],{},"Unit tests"," for composables, stores, and pure utility functions. Fast, no browser needed, runs in Node.js.",[15,4425,4426,4429],{},[32,4427,4428],{},"Component tests"," for Vue components. Validates rendering, user interactions, and prop/emit behavior. Uses jsdom or happy-dom to simulate a browser environment.",[15,4431,4432,4435],{},[32,4433,4434],{},"E2E tests"," for critical user flows. Runs a real browser against a running application. Slower but catches integration bugs that unit tests miss.",[15,4437,4438],{},"The ratio I aim for: many unit tests, reasonable component tests for complex components, and a focused set of E2E tests for critical paths.",[22,4440,4442],{"id":4441},"installing-nuxttest-utils","Installing @nuxt/test-utils",[64,4444,4446],{"className":66,"code":4445,"language":68,"meta":69,"style":69},"npm install --save-dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core\n",[59,4447,4448],{"__ignoreMap":69},[73,4449,4450,4452,4454,4456,4459,4462,4465,4468],{"class":75,"line":76},[73,4451,80],{"class":79},[73,4453,84],{"class":83},[73,4455,88],{"class":87},[73,4457,4458],{"class":83}," @nuxt/test-utils",[73,4460,4461],{"class":83}," vitest",[73,4463,4464],{"class":83}," @vue/test-utils",[73,4466,4467],{"class":83}," happy-dom",[73,4469,4470],{"class":83}," playwright-core\n",[15,4472,4473,4474,2425],{},"Configure Vitest in ",[59,4475,4476],{},"vitest.config.ts",[64,4478,4480],{"className":97,"code":4479,"language":99,"meta":69,"style":69},"import { defineVitestConfig } from '@nuxt/test-utils/config'\n\nExport default defineVitestConfig({\n test: {\n environment: 'nuxt',\n environmentOptions: {\n nuxt: {\n rootDir: '.',\n domEnvironment: 'happy-dom',\n },\n },\n coverage: {\n provider: 'v8',\n reporter: ['text', 'lcov'],\n include: ['composables/**', 'stores/**', 'utils/**', 'components/**'],\n exclude: ['node_modules', '.nuxt', 'server/**'],\n },\n },\n})\n",[59,4481,4482,4494,4498,4509,4514,4524,4529,4534,4544,4554,4558,4562,4567,4577,4592,4617,4637,4641,4645],{"__ignoreMap":69},[73,4483,4484,4486,4489,4491],{"class":75,"line":76},[73,4485,2143],{"class":631},[73,4487,4488],{"class":116}," { defineVitestConfig } ",[73,4490,2149],{"class":631},[73,4492,4493],{"class":83}," '@nuxt/test-utils/config'\n",[73,4495,4496],{"class":75,"line":110},[73,4497,130],{"emptyLinePlaceholder":129},[73,4499,4500,4502,4504,4507],{"class":75,"line":126},[73,4501,2208],{"class":116},[73,4503,2211],{"class":631},[73,4505,4506],{"class":79}," defineVitestConfig",[73,4508,1336],{"class":116},[73,4510,4511],{"class":75,"line":133},[73,4512,4513],{"class":116}," test: {\n",[73,4515,4516,4519,4522],{"class":75,"line":142},[73,4517,4518],{"class":116}," environment: ",[73,4520,4521],{"class":83},"'nuxt'",[73,4523,154],{"class":116},[73,4525,4526],{"class":75,"line":157},[73,4527,4528],{"class":116}," environmentOptions: {\n",[73,4530,4531],{"class":75,"line":165},[73,4532,4533],{"class":116}," nuxt: {\n",[73,4535,4536,4539,4542],{"class":75,"line":178},[73,4537,4538],{"class":116}," rootDir: ",[73,4540,4541],{"class":83},"'.'",[73,4543,154],{"class":116},[73,4545,4546,4549,4552],{"class":75,"line":191},[73,4547,4548],{"class":116}," domEnvironment: ",[73,4550,4551],{"class":83},"'happy-dom'",[73,4553,154],{"class":116},[73,4555,4556],{"class":75,"line":204},[73,4557,307],{"class":116},[73,4559,4560],{"class":75,"line":217},[73,4561,307],{"class":116},[73,4563,4564],{"class":75,"line":230},[73,4565,4566],{"class":116}," coverage: {\n",[73,4568,4569,4572,4575],{"class":75,"line":243},[73,4570,4571],{"class":116}," provider: ",[73,4573,4574],{"class":83},"'v8'",[73,4576,154],{"class":116},[73,4578,4579,4582,4585,4587,4590],{"class":75,"line":256},[73,4580,4581],{"class":116}," reporter: [",[73,4583,4584],{"class":83},"'text'",[73,4586,710],{"class":116},[73,4588,4589],{"class":83},"'lcov'",[73,4591,123],{"class":116},[73,4593,4594,4597,4600,4602,4605,4607,4610,4612,4615],{"class":75,"line":265},[73,4595,4596],{"class":116}," include: [",[73,4598,4599],{"class":83},"'composables/**'",[73,4601,710],{"class":116},[73,4603,4604],{"class":83},"'stores/**'",[73,4606,710],{"class":116},[73,4608,4609],{"class":83},"'utils/**'",[73,4611,710],{"class":116},[73,4613,4614],{"class":83},"'components/**'",[73,4616,123],{"class":116},[73,4618,4619,4622,4625,4627,4630,4632,4635],{"class":75,"line":271},[73,4620,4621],{"class":116}," exclude: [",[73,4623,4624],{"class":83},"'node_modules'",[73,4626,710],{"class":116},[73,4628,4629],{"class":83},"'.nuxt'",[73,4631,710],{"class":116},[73,4633,4634],{"class":83},"'server/**'",[73,4636,123],{"class":116},[73,4638,4639],{"class":75,"line":282},[73,4640,307],{"class":116},[73,4642,4643],{"class":75,"line":293},[73,4644,307],{"class":116},[73,4646,4647],{"class":75,"line":304},[73,4648,1379],{"class":116},[15,4650,4651,4652,2425],{},"Add test scripts to ",[59,4653,4654],{},"package.json",[64,4656,4660],{"className":4657,"code":4658,"language":4659,"meta":69,"style":69},"language-json shiki shiki-themes github-dark","{\n \"scripts\": {\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"test:e2e\": \"playwright test\"\n }\n}\n","json",[59,4661,4662,4667,4674,4686,4698,4710,4720,4724],{"__ignoreMap":69},[73,4663,4664],{"class":75,"line":76},[73,4665,4666],{"class":116},"{\n",[73,4668,4669,4672],{"class":75,"line":110},[73,4670,4671],{"class":87}," \"scripts\"",[73,4673,139],{"class":116},[73,4675,4676,4679,4681,4684],{"class":75,"line":126},[73,4677,4678],{"class":87}," \"test\"",[73,4680,148],{"class":116},[73,4682,4683],{"class":83},"\"vitest run\"",[73,4685,154],{"class":116},[73,4687,4688,4691,4693,4696],{"class":75,"line":133},[73,4689,4690],{"class":87}," \"test:watch\"",[73,4692,148],{"class":116},[73,4694,4695],{"class":83},"\"vitest\"",[73,4697,154],{"class":116},[73,4699,4700,4703,4705,4708],{"class":75,"line":142},[73,4701,4702],{"class":87}," \"test:coverage\"",[73,4704,148],{"class":116},[73,4706,4707],{"class":83},"\"vitest run --coverage\"",[73,4709,154],{"class":116},[73,4711,4712,4715,4717],{"class":75,"line":157},[73,4713,4714],{"class":87}," \"test:e2e\"",[73,4716,148],{"class":116},[73,4718,4719],{"class":83},"\"playwright test\"\n",[73,4721,4722],{"class":75,"line":165},[73,4723,1889],{"class":116},[73,4725,4726],{"class":75,"line":178},[73,4727,1098],{"class":116},[22,4729,4731],{"id":4730},"testing-composables","Testing Composables",[15,4733,4734,4735,4738],{},"Composables are the easiest things to test because they are just functions. The ",[59,4736,4737],{},"@nuxt/test-utils"," environment sets up the Nuxt context so auto-imports work:",[64,4740,4742],{"className":97,"code":4741,"language":99,"meta":69,"style":69},"// composables/useCounter.ts\nexport function useCounter(initial = 0) {\n const count = ref(initial)\n const doubled = computed(() => count.value * 2)\n\n function increment() { count.value++ }\n function decrement() { count.value-- }\n function reset() { count.value = initial }\n\n return { count, doubled, increment, decrement, reset }\n}\n",[59,4743,4744,4749,4770,4784,4811,4815,4830,4844,4859,4863,4870],{"__ignoreMap":69},[73,4745,4746],{"class":75,"line":76},[73,4747,4748],{"class":106},"// composables/useCounter.ts\n",[73,4750,4751,4753,4755,4758,4760,4763,4765,4768],{"class":75,"line":110},[73,4752,936],{"class":631},[73,4754,939],{"class":631},[73,4756,4757],{"class":79}," useCounter",[73,4759,977],{"class":116},[73,4761,4762],{"class":624},"initial",[73,4764,956],{"class":631},[73,4766,4767],{"class":87}," 0",[73,4769,1349],{"class":116},[73,4771,4772,4774,4777,4779,4781],{"class":75,"line":126},[73,4773,950],{"class":631},[73,4775,4776],{"class":87}," count",[73,4778,956],{"class":631},[73,4780,959],{"class":79},[73,4782,4783],{"class":116},"(initial)\n",[73,4785,4786,4788,4791,4793,4796,4798,4800,4803,4806,4809],{"class":75,"line":133},[73,4787,950],{"class":631},[73,4789,4790],{"class":87}," doubled",[73,4792,956],{"class":631},[73,4794,4795],{"class":79}," computed",[73,4797,1033],{"class":116},[73,4799,632],{"class":631},[73,4801,4802],{"class":116}," count.value ",[73,4804,4805],{"class":631},"*",[73,4807,4808],{"class":87}," 2",[73,4810,1370],{"class":116},[73,4812,4813],{"class":75,"line":142},[73,4814,130],{"emptyLinePlaceholder":129},[73,4816,4817,4819,4822,4825,4828],{"class":75,"line":157},[73,4818,939],{"class":631},[73,4820,4821],{"class":79}," increment",[73,4823,4824],{"class":116},"() { count.value",[73,4826,4827],{"class":631},"++",[73,4829,1889],{"class":116},[73,4831,4832,4834,4837,4839,4842],{"class":75,"line":165},[73,4833,939],{"class":631},[73,4835,4836],{"class":79}," decrement",[73,4838,4824],{"class":116},[73,4840,4841],{"class":631},"--",[73,4843,1889],{"class":116},[73,4845,4846,4848,4851,4854,4856],{"class":75,"line":178},[73,4847,939],{"class":631},[73,4849,4850],{"class":79}," reset",[73,4852,4853],{"class":116},"() { count.value ",[73,4855,991],{"class":631},[73,4857,4858],{"class":116}," initial }\n",[73,4860,4861],{"class":75,"line":191},[73,4862,130],{"emptyLinePlaceholder":129},[73,4864,4865,4867],{"class":75,"line":204},[73,4866,1084],{"class":631},[73,4868,4869],{"class":116}," { count, doubled, increment, decrement, reset }\n",[73,4871,4872],{"class":75,"line":217},[73,4873,1098],{"class":116},[64,4875,4877],{"className":97,"code":4876,"language":99,"meta":69,"style":69},"// composables/__tests__/useCounter.test.ts\nimport { describe, it, expect } from 'vitest'\n\nDescribe('useCounter', () => {\n it('initializes with default value', () => {\n const { count } = useCounter()\n expect(count.value).toBe(0)\n })\n\n it('initializes with provided value', () => {\n const { count } = useCounter(5)\n expect(count.value).toBe(5)\n })\n\n it('increments count', () => {\n const { count, increment } = useCounter()\n increment()\n expect(count.value).toBe(1)\n })\n\n it('computes doubled value', () => {\n const { count, doubled, increment } = useCounter(3)\n expect(doubled.value).toBe(6)\n increment()\n expect(doubled.value).toBe(8)\n })\n\n it('resets to initial value', () => {\n const { count, increment, reset } = useCounter(5)\n increment()\n increment()\n reset()\n expect(count.value).toBe(5)\n })\n})\n",[59,4878,4879,4884,4896,4900,4916,4932,4949,4967,4971,4975,4990,5010,5024,5028,5032,5047,5068,5074,5089,5093,5097,5112,5142,5158,5164,5179,5183,5187,5202,5231,5237,5243,5249,5263,5267],{"__ignoreMap":69},[73,4880,4881],{"class":75,"line":76},[73,4882,4883],{"class":106},"// composables/__tests__/useCounter.test.ts\n",[73,4885,4886,4888,4891,4893],{"class":75,"line":110},[73,4887,2143],{"class":631},[73,4889,4890],{"class":116}," { describe, it, expect } ",[73,4892,2149],{"class":631},[73,4894,4895],{"class":83}," 'vitest'\n",[73,4897,4898],{"class":75,"line":126},[73,4899,130],{"emptyLinePlaceholder":129},[73,4901,4902,4905,4907,4910,4912,4914],{"class":75,"line":133},[73,4903,4904],{"class":79},"Describe",[73,4906,977],{"class":116},[73,4908,4909],{"class":83},"'useCounter'",[73,4911,983],{"class":116},[73,4913,632],{"class":631},[73,4915,268],{"class":116},[73,4917,4918,4921,4923,4926,4928,4930],{"class":75,"line":142},[73,4919,4920],{"class":79}," it",[73,4922,977],{"class":116},[73,4924,4925],{"class":83},"'initializes with default value'",[73,4927,983],{"class":116},[73,4929,632],{"class":631},[73,4931,268],{"class":116},[73,4933,4934,4936,4938,4941,4943,4945,4947],{"class":75,"line":157},[73,4935,950],{"class":631},[73,4937,1141],{"class":116},[73,4939,4940],{"class":87},"count",[73,4942,1147],{"class":116},[73,4944,991],{"class":631},[73,4946,4757],{"class":79},[73,4948,1154],{"class":116},[73,4950,4951,4954,4957,4960,4962,4965],{"class":75,"line":165},[73,4952,4953],{"class":79}," expect",[73,4955,4956],{"class":116},"(count.value).",[73,4958,4959],{"class":79},"toBe",[73,4961,977],{"class":116},[73,4963,4964],{"class":87},"0",[73,4966,1370],{"class":116},[73,4968,4969],{"class":75,"line":178},[73,4970,997],{"class":116},[73,4972,4973],{"class":75,"line":191},[73,4974,130],{"emptyLinePlaceholder":129},[73,4976,4977,4979,4981,4984,4986,4988],{"class":75,"line":204},[73,4978,4920],{"class":79},[73,4980,977],{"class":116},[73,4982,4983],{"class":83},"'initializes with provided value'",[73,4985,983],{"class":116},[73,4987,632],{"class":631},[73,4989,268],{"class":116},[73,4991,4992,4994,4996,4998,5000,5002,5004,5006,5008],{"class":75,"line":217},[73,4993,950],{"class":631},[73,4995,1141],{"class":116},[73,4997,4940],{"class":87},[73,4999,1147],{"class":116},[73,5001,991],{"class":631},[73,5003,4757],{"class":79},[73,5005,977],{"class":116},[73,5007,832],{"class":87},[73,5009,1370],{"class":116},[73,5011,5012,5014,5016,5018,5020,5022],{"class":75,"line":230},[73,5013,4953],{"class":79},[73,5015,4956],{"class":116},[73,5017,4959],{"class":79},[73,5019,977],{"class":116},[73,5021,832],{"class":87},[73,5023,1370],{"class":116},[73,5025,5026],{"class":75,"line":243},[73,5027,997],{"class":116},[73,5029,5030],{"class":75,"line":256},[73,5031,130],{"emptyLinePlaceholder":129},[73,5033,5034,5036,5038,5041,5043,5045],{"class":75,"line":265},[73,5035,4920],{"class":79},[73,5037,977],{"class":116},[73,5039,5040],{"class":83},"'increments count'",[73,5042,983],{"class":116},[73,5044,632],{"class":631},[73,5046,268],{"class":116},[73,5048,5049,5051,5053,5055,5057,5060,5062,5064,5066],{"class":75,"line":271},[73,5050,950],{"class":631},[73,5052,1141],{"class":116},[73,5054,4940],{"class":87},[73,5056,710],{"class":116},[73,5058,5059],{"class":87},"increment",[73,5061,1147],{"class":116},[73,5063,991],{"class":631},[73,5065,4757],{"class":79},[73,5067,1154],{"class":116},[73,5069,5070,5072],{"class":75,"line":282},[73,5071,4821],{"class":79},[73,5073,1154],{"class":116},[73,5075,5076,5078,5080,5082,5084,5087],{"class":75,"line":293},[73,5077,4953],{"class":79},[73,5079,4956],{"class":116},[73,5081,4959],{"class":79},[73,5083,977],{"class":116},[73,5085,5086],{"class":87},"1",[73,5088,1370],{"class":116},[73,5090,5091],{"class":75,"line":304},[73,5092,997],{"class":116},[73,5094,5095],{"class":75,"line":310},[73,5096,130],{"emptyLinePlaceholder":129},[73,5098,5099,5101,5103,5106,5108,5110],{"class":75,"line":315},[73,5100,4920],{"class":79},[73,5102,977],{"class":116},[73,5104,5105],{"class":83},"'computes doubled value'",[73,5107,983],{"class":116},[73,5109,632],{"class":631},[73,5111,268],{"class":116},[73,5113,5114,5116,5118,5120,5122,5125,5127,5129,5131,5133,5135,5137,5140],{"class":75,"line":325},[73,5115,950],{"class":631},[73,5117,1141],{"class":116},[73,5119,4940],{"class":87},[73,5121,710],{"class":116},[73,5123,5124],{"class":87},"doubled",[73,5126,710],{"class":116},[73,5128,5059],{"class":87},[73,5130,1147],{"class":116},[73,5132,991],{"class":631},[73,5134,4757],{"class":79},[73,5136,977],{"class":116},[73,5138,5139],{"class":87},"3",[73,5141,1370],{"class":116},[73,5143,5144,5146,5149,5151,5153,5156],{"class":75,"line":335},[73,5145,4953],{"class":79},[73,5147,5148],{"class":116},"(doubled.value).",[73,5150,4959],{"class":79},[73,5152,977],{"class":116},[73,5154,5155],{"class":87},"6",[73,5157,1370],{"class":116},[73,5159,5160,5162],{"class":75,"line":344},[73,5161,4821],{"class":79},[73,5163,1154],{"class":116},[73,5165,5166,5168,5170,5172,5174,5177],{"class":75,"line":349},[73,5167,4953],{"class":79},[73,5169,5148],{"class":116},[73,5171,4959],{"class":79},[73,5173,977],{"class":116},[73,5175,5176],{"class":87},"8",[73,5178,1370],{"class":116},[73,5180,5181],{"class":75,"line":354},[73,5182,997],{"class":116},[73,5184,5185],{"class":75,"line":363},[73,5186,130],{"emptyLinePlaceholder":129},[73,5188,5189,5191,5193,5196,5198,5200],{"class":75,"line":372},[73,5190,4920],{"class":79},[73,5192,977],{"class":116},[73,5194,5195],{"class":83},"'resets to initial value'",[73,5197,983],{"class":116},[73,5199,632],{"class":631},[73,5201,268],{"class":116},[73,5203,5204,5206,5208,5210,5212,5214,5216,5219,5221,5223,5225,5227,5229],{"class":75,"line":381},[73,5205,950],{"class":631},[73,5207,1141],{"class":116},[73,5209,4940],{"class":87},[73,5211,710],{"class":116},[73,5213,5059],{"class":87},[73,5215,710],{"class":116},[73,5217,5218],{"class":87},"reset",[73,5220,1147],{"class":116},[73,5222,991],{"class":631},[73,5224,4757],{"class":79},[73,5226,977],{"class":116},[73,5228,832],{"class":87},[73,5230,1370],{"class":116},[73,5232,5233,5235],{"class":75,"line":392},[73,5234,4821],{"class":79},[73,5236,1154],{"class":116},[73,5238,5239,5241],{"class":75,"line":397},[73,5240,4821],{"class":79},[73,5242,1154],{"class":116},[73,5244,5245,5247],{"class":75,"line":403},[73,5246,4850],{"class":79},[73,5248,1154],{"class":116},[73,5250,5251,5253,5255,5257,5259,5261],{"class":75,"line":408},[73,5252,4953],{"class":79},[73,5254,4956],{"class":116},[73,5256,4959],{"class":79},[73,5258,977],{"class":116},[73,5260,832],{"class":87},[73,5262,1370],{"class":116},[73,5264,5265],{"class":75,"line":416},[73,5266,997],{"class":116},[73,5268,5269],{"class":75,"line":429},[73,5270,1379],{"class":116},[15,5272,5273,5274,5277],{},"For composables that make API calls, mock the fetch calls with ",[59,5275,5276],{},"vi.fn()"," or use MSW (Mock Service Worker):",[64,5279,5281],{"className":97,"code":5280,"language":99,"meta":69,"style":69},"// composables/__tests__/usePosts.test.ts\nimport { vi, describe, it, expect, beforeEach } from 'vitest'\n\nVi.mock('#app', () => ({\n useFetch: vi.fn(),\n}))\n\nDescribe('usePosts', () => {\n beforeEach(() => {\n vi.clearAllMocks()\n })\n\n it('returns posts from API', async () => {\n const mockPosts = [\n { id: '1', title: 'Test Post', slug: 'test-post' },\n ]\n\n vi.mocked(useFetch).mockResolvedValue({\n data: ref(mockPosts),\n pending: ref(false),\n error: ref(null),\n refresh: vi.fn(),\n })\n\n const { posts, loading } = await usePosts()\n expect(posts.value).toEqual(mockPosts)\n expect(loading.value).toBe(false)\n })\n})\n",[59,5282,5283,5288,5299,5303,5323,5333,5338,5342,5357,5368,5378,5382,5386,5406,5418,5440,5445,5449,5464,5475,5489,5502,5511,5515,5519,5543,5556,5571,5575],{"__ignoreMap":69},[73,5284,5285],{"class":75,"line":76},[73,5286,5287],{"class":106},"// composables/__tests__/usePosts.test.ts\n",[73,5289,5290,5292,5295,5297],{"class":75,"line":110},[73,5291,2143],{"class":631},[73,5293,5294],{"class":116}," { vi, describe, it, expect, beforeEach } ",[73,5296,2149],{"class":631},[73,5298,4895],{"class":83},[73,5300,5301],{"class":75,"line":126},[73,5302,130],{"emptyLinePlaceholder":129},[73,5304,5305,5308,5311,5313,5316,5318,5320],{"class":75,"line":133},[73,5306,5307],{"class":116},"Vi.",[73,5309,5310],{"class":79},"mock",[73,5312,977],{"class":116},[73,5314,5315],{"class":83},"'#app'",[73,5317,983],{"class":116},[73,5319,632],{"class":631},[73,5321,5322],{"class":116}," ({\n",[73,5324,5325,5328,5331],{"class":75,"line":142},[73,5326,5327],{"class":116}," useFetch: vi.",[73,5329,5330],{"class":79},"fn",[73,5332,2117],{"class":116},[73,5334,5335],{"class":75,"line":157},[73,5336,5337],{"class":116},"}))\n",[73,5339,5340],{"class":75,"line":165},[73,5341,130],{"emptyLinePlaceholder":129},[73,5343,5344,5346,5348,5351,5353,5355],{"class":75,"line":178},[73,5345,4904],{"class":79},[73,5347,977],{"class":116},[73,5349,5350],{"class":83},"'usePosts'",[73,5352,983],{"class":116},[73,5354,632],{"class":631},[73,5356,268],{"class":116},[73,5358,5359,5362,5364,5366],{"class":75,"line":191},[73,5360,5361],{"class":79}," beforeEach",[73,5363,1033],{"class":116},[73,5365,632],{"class":631},[73,5367,268],{"class":116},[73,5369,5370,5373,5376],{"class":75,"line":204},[73,5371,5372],{"class":116}," vi.",[73,5374,5375],{"class":79},"clearAllMocks",[73,5377,1154],{"class":116},[73,5379,5380],{"class":75,"line":217},[73,5381,997],{"class":116},[73,5383,5384],{"class":75,"line":230},[73,5385,130],{"emptyLinePlaceholder":129},[73,5387,5388,5390,5392,5395,5397,5399,5402,5404],{"class":75,"line":243},[73,5389,4920],{"class":79},[73,5391,977],{"class":116},[73,5393,5394],{"class":83},"'returns posts from API'",[73,5396,710],{"class":116},[73,5398,1996],{"class":631},[73,5400,5401],{"class":116}," () ",[73,5403,632],{"class":631},[73,5405,268],{"class":116},[73,5407,5408,5410,5413,5415],{"class":75,"line":256},[73,5409,950],{"class":631},[73,5411,5412],{"class":87}," mockPosts",[73,5414,956],{"class":631},[73,5416,5417],{"class":116}," [\n",[73,5419,5420,5423,5426,5429,5432,5435,5438],{"class":75,"line":265},[73,5421,5422],{"class":116}," { id: ",[73,5424,5425],{"class":83},"'1'",[73,5427,5428],{"class":116},", title: ",[73,5430,5431],{"class":83},"'Test Post'",[73,5433,5434],{"class":116},", slug: ",[73,5436,5437],{"class":83},"'test-post'",[73,5439,307],{"class":116},[73,5441,5442],{"class":75,"line":271},[73,5443,5444],{"class":116}," ]\n",[73,5446,5447],{"class":75,"line":282},[73,5448,130],{"emptyLinePlaceholder":129},[73,5450,5451,5453,5456,5459,5462],{"class":75,"line":293},[73,5452,5372],{"class":116},[73,5454,5455],{"class":79},"mocked",[73,5457,5458],{"class":116},"(useFetch).",[73,5460,5461],{"class":79},"mockResolvedValue",[73,5463,1336],{"class":116},[73,5465,5466,5469,5472],{"class":75,"line":304},[73,5467,5468],{"class":116}," data: ",[73,5470,5471],{"class":79},"ref",[73,5473,5474],{"class":116},"(mockPosts),\n",[73,5476,5477,5480,5482,5484,5487],{"class":75,"line":310},[73,5478,5479],{"class":116}," pending: ",[73,5481,5471],{"class":79},[73,5483,977],{"class":116},[73,5485,5486],{"class":87},"false",[73,5488,1934],{"class":116},[73,5490,5491,5494,5496,5498,5500],{"class":75,"line":315},[73,5492,5493],{"class":116}," error: ",[73,5495,5471],{"class":79},[73,5497,977],{"class":116},[73,5499,1664],{"class":87},[73,5501,1934],{"class":116},[73,5503,5504,5507,5509],{"class":75,"line":325},[73,5505,5506],{"class":116}," refresh: vi.",[73,5508,5330],{"class":79},[73,5510,2117],{"class":116},[73,5512,5513],{"class":75,"line":335},[73,5514,997],{"class":116},[73,5516,5517],{"class":75,"line":344},[73,5518,130],{"emptyLinePlaceholder":129},[73,5520,5521,5523,5525,5528,5530,5532,5534,5536,5538,5541],{"class":75,"line":349},[73,5522,950],{"class":631},[73,5524,1141],{"class":116},[73,5526,5527],{"class":87},"posts",[73,5529,710],{"class":116},[73,5531,3476],{"class":87},[73,5533,1147],{"class":116},[73,5535,991],{"class":631},[73,5537,1401],{"class":631},[73,5539,5540],{"class":79}," usePosts",[73,5542,1154],{"class":116},[73,5544,5545,5547,5550,5553],{"class":75,"line":354},[73,5546,4953],{"class":79},[73,5548,5549],{"class":116},"(posts.value).",[73,5551,5552],{"class":79},"toEqual",[73,5554,5555],{"class":116},"(mockPosts)\n",[73,5557,5558,5560,5563,5565,5567,5569],{"class":75,"line":363},[73,5559,4953],{"class":79},[73,5561,5562],{"class":116},"(loading.value).",[73,5564,4959],{"class":79},[73,5566,977],{"class":116},[73,5568,5486],{"class":87},[73,5570,1370],{"class":116},[73,5572,5573],{"class":75,"line":372},[73,5574,997],{"class":116},[73,5576,5577],{"class":75,"line":381},[73,5578,1379],{"class":116},[22,5580,5582],{"id":5581},"testing-pinia-stores","Testing Pinia Stores",[15,5584,5585,5586,5589],{},"Store tests are straightforward with ",[59,5587,5588],{},"createPinia"," from the test utilities:",[64,5591,5593],{"className":97,"code":5592,"language":99,"meta":69,"style":69},"// stores/__tests__/cart.test.ts\nimport { describe, it, expect, beforeEach } from 'vitest'\nimport { setActivePinia, createPinia } from 'pinia'\nimport { useCartStore } from '../cart'\n\nDescribe('CartStore', () => {\n beforeEach(() => {\n setActivePinia(createPinia())\n })\n\n it('starts with empty cart', () => {\n const cart = useCartStore()\n expect(cart.items).toHaveLength(0)\n expect(cart.total).toBe(0)\n })\n\n it('adds an item to cart', () => {\n const cart = useCartStore()\n cart.addItem({ productId: 'p1', name: 'Widget', price: 29.99, quantity: 1 })\n expect(cart.items).toHaveLength(1)\n expect(cart.total).toBe(29.99)\n })\n\n it('increments quantity for duplicate items', () => {\n const cart = useCartStore()\n cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 1 })\n cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 2 })\n expect(cart.items).toHaveLength(1)\n expect(cart.items[0].quantity).toBe(3)\n expect(cart.total).toBe(30)\n })\n\n it('removes an item', () => {\n const cart = useCartStore()\n cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 1 })\n cart.removeItem('p1')\n expect(cart.items).toHaveLength(0)\n })\n})\n",[59,5594,5595,5600,5611,5623,5635,5639,5654,5664,5676,5680,5684,5699,5713,5729,5744,5748,5752,5767,5779,5812,5826,5840,5844,5848,5863,5875,5900,5925,5939,5959,5974,5978,5982,5997,6009,6033,6046,6060,6064],{"__ignoreMap":69},[73,5596,5597],{"class":75,"line":76},[73,5598,5599],{"class":106},"// stores/__tests__/cart.test.ts\n",[73,5601,5602,5604,5607,5609],{"class":75,"line":110},[73,5603,2143],{"class":631},[73,5605,5606],{"class":116}," { describe, it, expect, beforeEach } ",[73,5608,2149],{"class":631},[73,5610,4895],{"class":83},[73,5612,5613,5615,5618,5620],{"class":75,"line":126},[73,5614,2143],{"class":631},[73,5616,5617],{"class":116}," { setActivePinia, createPinia } ",[73,5619,2149],{"class":631},[73,5621,5622],{"class":83}," 'pinia'\n",[73,5624,5625,5627,5630,5632],{"class":75,"line":133},[73,5626,2143],{"class":631},[73,5628,5629],{"class":116}," { useCartStore } ",[73,5631,2149],{"class":631},[73,5633,5634],{"class":83}," '../cart'\n",[73,5636,5637],{"class":75,"line":142},[73,5638,130],{"emptyLinePlaceholder":129},[73,5640,5641,5643,5645,5648,5650,5652],{"class":75,"line":157},[73,5642,4904],{"class":79},[73,5644,977],{"class":116},[73,5646,5647],{"class":83},"'CartStore'",[73,5649,983],{"class":116},[73,5651,632],{"class":631},[73,5653,268],{"class":116},[73,5655,5656,5658,5660,5662],{"class":75,"line":165},[73,5657,5361],{"class":79},[73,5659,1033],{"class":116},[73,5661,632],{"class":631},[73,5663,268],{"class":116},[73,5665,5666,5669,5671,5673],{"class":75,"line":178},[73,5667,5668],{"class":79}," setActivePinia",[73,5670,977],{"class":116},[73,5672,5588],{"class":79},[73,5674,5675],{"class":116},"())\n",[73,5677,5678],{"class":75,"line":191},[73,5679,997],{"class":116},[73,5681,5682],{"class":75,"line":204},[73,5683,130],{"emptyLinePlaceholder":129},[73,5685,5686,5688,5690,5693,5695,5697],{"class":75,"line":217},[73,5687,4920],{"class":79},[73,5689,977],{"class":116},[73,5691,5692],{"class":83},"'starts with empty cart'",[73,5694,983],{"class":116},[73,5696,632],{"class":631},[73,5698,268],{"class":116},[73,5700,5701,5703,5706,5708,5711],{"class":75,"line":230},[73,5702,950],{"class":631},[73,5704,5705],{"class":87}," cart",[73,5707,956],{"class":631},[73,5709,5710],{"class":79}," useCartStore",[73,5712,1154],{"class":116},[73,5714,5715,5717,5720,5723,5725,5727],{"class":75,"line":243},[73,5716,4953],{"class":79},[73,5718,5719],{"class":116},"(cart.items).",[73,5721,5722],{"class":79},"toHaveLength",[73,5724,977],{"class":116},[73,5726,4964],{"class":87},[73,5728,1370],{"class":116},[73,5730,5731,5733,5736,5738,5740,5742],{"class":75,"line":256},[73,5732,4953],{"class":79},[73,5734,5735],{"class":116},"(cart.total).",[73,5737,4959],{"class":79},[73,5739,977],{"class":116},[73,5741,4964],{"class":87},[73,5743,1370],{"class":116},[73,5745,5746],{"class":75,"line":265},[73,5747,997],{"class":116},[73,5749,5750],{"class":75,"line":271},[73,5751,130],{"emptyLinePlaceholder":129},[73,5753,5754,5756,5758,5761,5763,5765],{"class":75,"line":282},[73,5755,4920],{"class":79},[73,5757,977],{"class":116},[73,5759,5760],{"class":83},"'adds an item to cart'",[73,5762,983],{"class":116},[73,5764,632],{"class":631},[73,5766,268],{"class":116},[73,5768,5769,5771,5773,5775,5777],{"class":75,"line":293},[73,5770,950],{"class":631},[73,5772,5705],{"class":87},[73,5774,956],{"class":631},[73,5776,5710],{"class":79},[73,5778,1154],{"class":116},[73,5780,5781,5784,5787,5790,5793,5796,5799,5802,5805,5808,5810],{"class":75,"line":304},[73,5782,5783],{"class":116}," cart.",[73,5785,5786],{"class":79},"addItem",[73,5788,5789],{"class":116},"({ productId: ",[73,5791,5792],{"class":83},"'p1'",[73,5794,5795],{"class":116},", name: ",[73,5797,5798],{"class":83},"'Widget'",[73,5800,5801],{"class":116},", price: ",[73,5803,5804],{"class":87},"29.99",[73,5806,5807],{"class":116},", quantity: ",[73,5809,5086],{"class":87},[73,5811,997],{"class":116},[73,5813,5814,5816,5818,5820,5822,5824],{"class":75,"line":310},[73,5815,4953],{"class":79},[73,5817,5719],{"class":116},[73,5819,5722],{"class":79},[73,5821,977],{"class":116},[73,5823,5086],{"class":87},[73,5825,1370],{"class":116},[73,5827,5828,5830,5832,5834,5836,5838],{"class":75,"line":315},[73,5829,4953],{"class":79},[73,5831,5735],{"class":116},[73,5833,4959],{"class":79},[73,5835,977],{"class":116},[73,5837,5804],{"class":87},[73,5839,1370],{"class":116},[73,5841,5842],{"class":75,"line":325},[73,5843,997],{"class":116},[73,5845,5846],{"class":75,"line":335},[73,5847,130],{"emptyLinePlaceholder":129},[73,5849,5850,5852,5854,5857,5859,5861],{"class":75,"line":344},[73,5851,4920],{"class":79},[73,5853,977],{"class":116},[73,5855,5856],{"class":83},"'increments quantity for duplicate items'",[73,5858,983],{"class":116},[73,5860,632],{"class":631},[73,5862,268],{"class":116},[73,5864,5865,5867,5869,5871,5873],{"class":75,"line":349},[73,5866,950],{"class":631},[73,5868,5705],{"class":87},[73,5870,956],{"class":631},[73,5872,5710],{"class":79},[73,5874,1154],{"class":116},[73,5876,5877,5879,5881,5883,5885,5887,5889,5891,5894,5896,5898],{"class":75,"line":354},[73,5878,5783],{"class":116},[73,5880,5786],{"class":79},[73,5882,5789],{"class":116},[73,5884,5792],{"class":83},[73,5886,5795],{"class":116},[73,5888,5798],{"class":83},[73,5890,5801],{"class":116},[73,5892,5893],{"class":87},"10",[73,5895,5807],{"class":116},[73,5897,5086],{"class":87},[73,5899,997],{"class":116},[73,5901,5902,5904,5906,5908,5910,5912,5914,5916,5918,5920,5923],{"class":75,"line":363},[73,5903,5783],{"class":116},[73,5905,5786],{"class":79},[73,5907,5789],{"class":116},[73,5909,5792],{"class":83},[73,5911,5795],{"class":116},[73,5913,5798],{"class":83},[73,5915,5801],{"class":116},[73,5917,5893],{"class":87},[73,5919,5807],{"class":116},[73,5921,5922],{"class":87},"2",[73,5924,997],{"class":116},[73,5926,5927,5929,5931,5933,5935,5937],{"class":75,"line":372},[73,5928,4953],{"class":79},[73,5930,5719],{"class":116},[73,5932,5722],{"class":79},[73,5934,977],{"class":116},[73,5936,5086],{"class":87},[73,5938,1370],{"class":116},[73,5940,5941,5943,5946,5948,5951,5953,5955,5957],{"class":75,"line":381},[73,5942,4953],{"class":79},[73,5944,5945],{"class":116},"(cart.items[",[73,5947,4964],{"class":87},[73,5949,5950],{"class":116},"].quantity).",[73,5952,4959],{"class":79},[73,5954,977],{"class":116},[73,5956,5139],{"class":87},[73,5958,1370],{"class":116},[73,5960,5961,5963,5965,5967,5969,5972],{"class":75,"line":392},[73,5962,4953],{"class":79},[73,5964,5735],{"class":116},[73,5966,4959],{"class":79},[73,5968,977],{"class":116},[73,5970,5971],{"class":87},"30",[73,5973,1370],{"class":116},[73,5975,5976],{"class":75,"line":397},[73,5977,997],{"class":116},[73,5979,5980],{"class":75,"line":403},[73,5981,130],{"emptyLinePlaceholder":129},[73,5983,5984,5986,5988,5991,5993,5995],{"class":75,"line":408},[73,5985,4920],{"class":79},[73,5987,977],{"class":116},[73,5989,5990],{"class":83},"'removes an item'",[73,5992,983],{"class":116},[73,5994,632],{"class":631},[73,5996,268],{"class":116},[73,5998,5999,6001,6003,6005,6007],{"class":75,"line":416},[73,6000,950],{"class":631},[73,6002,5705],{"class":87},[73,6004,956],{"class":631},[73,6006,5710],{"class":79},[73,6008,1154],{"class":116},[73,6010,6011,6013,6015,6017,6019,6021,6023,6025,6027,6029,6031],{"class":75,"line":429},[73,6012,5783],{"class":116},[73,6014,5786],{"class":79},[73,6016,5789],{"class":116},[73,6018,5792],{"class":83},[73,6020,5795],{"class":116},[73,6022,5798],{"class":83},[73,6024,5801],{"class":116},[73,6026,5893],{"class":87},[73,6028,5807],{"class":116},[73,6030,5086],{"class":87},[73,6032,997],{"class":116},[73,6034,6035,6037,6040,6042,6044],{"class":75,"line":442},[73,6036,5783],{"class":116},[73,6038,6039],{"class":79},"removeItem",[73,6041,977],{"class":116},[73,6043,5792],{"class":83},[73,6045,1370],{"class":116},[73,6047,6048,6050,6052,6054,6056,6058],{"class":75,"line":455},[73,6049,4953],{"class":79},[73,6051,5719],{"class":116},[73,6053,5722],{"class":79},[73,6055,977],{"class":116},[73,6057,4964],{"class":87},[73,6059,1370],{"class":116},[73,6061,6062],{"class":75,"line":460},[73,6063,997],{"class":116},[73,6065,6066],{"class":75,"line":468},[73,6067,1379],{"class":116},[22,6069,6071],{"id":6070},"component-testing","Component Testing",[15,6073,6074],{},"Component tests verify rendering and user interactions:",[64,6076,6078],{"className":97,"code":6077,"language":99,"meta":69,"style":69},"// components/__tests__/AppButton.test.ts\nimport { describe, it, expect, vi } from 'vitest'\nimport { mount } from '@vue/test-utils'\nimport AppButton from '../AppButton.vue'\n\nDescribe('AppButton', () => {\n it('renders slot content', () => {\n const wrapper = mount(AppButton, {\n slots: { default: 'Click me' },\n })\n expect(wrapper.text()).toBe('Click me')\n })\n\n it('emits click event', async () => {\n const wrapper = mount(AppButton)\n await wrapper.trigger('click')\n expect(wrapper.emitted('click')).toBeTruthy()\n })\n\n it('is disabled when disabled prop is true', () => {\n const wrapper = mount(AppButton, {\n props: { disabled: true },\n })\n expect(wrapper.attributes('disabled')).toBeDefined()\n })\n\n it('shows loading spinner when loading', () => {\n const wrapper = mount(AppButton, {\n props: { loading: true },\n })\n expect(wrapper.find('[data-testid=\"spinner\"]').exists()).toBe(true)\n })\n\n it('applies correct variant classes', () => {\n const wrapper = mount(AppButton, {\n props: { variant: 'danger' },\n })\n expect(wrapper.classes()).toContain('bg-red-600')\n })\n})\n",[59,6079,6080,6085,6096,6108,6120,6124,6139,6154,6169,6179,6183,6204,6208,6212,6231,6244,6261,6282,6286,6290,6305,6317,6326,6330,6351,6355,6359,6374,6386,6395,6399,6428,6432,6436,6451,6463,6473,6477,6498,6502],{"__ignoreMap":69},[73,6081,6082],{"class":75,"line":76},[73,6083,6084],{"class":106},"// components/__tests__/AppButton.test.ts\n",[73,6086,6087,6089,6092,6094],{"class":75,"line":110},[73,6088,2143],{"class":631},[73,6090,6091],{"class":116}," { describe, it, expect, vi } ",[73,6093,2149],{"class":631},[73,6095,4895],{"class":83},[73,6097,6098,6100,6103,6105],{"class":75,"line":126},[73,6099,2143],{"class":631},[73,6101,6102],{"class":116}," { mount } ",[73,6104,2149],{"class":631},[73,6106,6107],{"class":83}," '@vue/test-utils'\n",[73,6109,6110,6112,6115,6117],{"class":75,"line":133},[73,6111,2143],{"class":631},[73,6113,6114],{"class":116}," AppButton ",[73,6116,2149],{"class":631},[73,6118,6119],{"class":83}," '../AppButton.vue'\n",[73,6121,6122],{"class":75,"line":142},[73,6123,130],{"emptyLinePlaceholder":129},[73,6125,6126,6128,6130,6133,6135,6137],{"class":75,"line":157},[73,6127,4904],{"class":79},[73,6129,977],{"class":116},[73,6131,6132],{"class":83},"'AppButton'",[73,6134,983],{"class":116},[73,6136,632],{"class":631},[73,6138,268],{"class":116},[73,6140,6141,6143,6145,6148,6150,6152],{"class":75,"line":165},[73,6142,4920],{"class":79},[73,6144,977],{"class":116},[73,6146,6147],{"class":83},"'renders slot content'",[73,6149,983],{"class":116},[73,6151,632],{"class":631},[73,6153,268],{"class":116},[73,6155,6156,6158,6161,6163,6166],{"class":75,"line":178},[73,6157,950],{"class":631},[73,6159,6160],{"class":87}," wrapper",[73,6162,956],{"class":631},[73,6164,6165],{"class":79}," mount",[73,6167,6168],{"class":116},"(AppButton, {\n",[73,6170,6171,6174,6177],{"class":75,"line":191},[73,6172,6173],{"class":116}," slots: { default: ",[73,6175,6176],{"class":83},"'Click me'",[73,6178,307],{"class":116},[73,6180,6181],{"class":75,"line":204},[73,6182,997],{"class":116},[73,6184,6185,6187,6190,6193,6196,6198,6200,6202],{"class":75,"line":217},[73,6186,4953],{"class":79},[73,6188,6189],{"class":116},"(wrapper.",[73,6191,6192],{"class":79},"text",[73,6194,6195],{"class":116},"()).",[73,6197,4959],{"class":79},[73,6199,977],{"class":116},[73,6201,6176],{"class":83},[73,6203,1370],{"class":116},[73,6205,6206],{"class":75,"line":230},[73,6207,997],{"class":116},[73,6209,6210],{"class":75,"line":243},[73,6211,130],{"emptyLinePlaceholder":129},[73,6213,6214,6216,6218,6221,6223,6225,6227,6229],{"class":75,"line":256},[73,6215,4920],{"class":79},[73,6217,977],{"class":116},[73,6219,6220],{"class":83},"'emits click event'",[73,6222,710],{"class":116},[73,6224,1996],{"class":631},[73,6226,5401],{"class":116},[73,6228,632],{"class":631},[73,6230,268],{"class":116},[73,6232,6233,6235,6237,6239,6241],{"class":75,"line":265},[73,6234,950],{"class":631},[73,6236,6160],{"class":87},[73,6238,956],{"class":631},[73,6240,6165],{"class":79},[73,6242,6243],{"class":116},"(AppButton)\n",[73,6245,6246,6248,6251,6254,6256,6259],{"class":75,"line":271},[73,6247,1401],{"class":631},[73,6249,6250],{"class":116}," wrapper.",[73,6252,6253],{"class":79},"trigger",[73,6255,977],{"class":116},[73,6257,6258],{"class":83},"'click'",[73,6260,1370],{"class":116},[73,6262,6263,6265,6267,6270,6272,6274,6277,6280],{"class":75,"line":282},[73,6264,4953],{"class":79},[73,6266,6189],{"class":116},[73,6268,6269],{"class":79},"emitted",[73,6271,977],{"class":116},[73,6273,6258],{"class":83},[73,6275,6276],{"class":116},")).",[73,6278,6279],{"class":79},"toBeTruthy",[73,6281,1154],{"class":116},[73,6283,6284],{"class":75,"line":293},[73,6285,997],{"class":116},[73,6287,6288],{"class":75,"line":304},[73,6289,130],{"emptyLinePlaceholder":129},[73,6291,6292,6294,6296,6299,6301,6303],{"class":75,"line":310},[73,6293,4920],{"class":79},[73,6295,977],{"class":116},[73,6297,6298],{"class":83},"'is disabled when disabled prop is true'",[73,6300,983],{"class":116},[73,6302,632],{"class":631},[73,6304,268],{"class":116},[73,6306,6307,6309,6311,6313,6315],{"class":75,"line":315},[73,6308,950],{"class":631},[73,6310,6160],{"class":87},[73,6312,956],{"class":631},[73,6314,6165],{"class":79},[73,6316,6168],{"class":116},[73,6318,6319,6322,6324],{"class":75,"line":325},[73,6320,6321],{"class":116}," props: { disabled: ",[73,6323,437],{"class":87},[73,6325,307],{"class":116},[73,6327,6328],{"class":75,"line":335},[73,6329,997],{"class":116},[73,6331,6332,6334,6336,6339,6341,6344,6346,6349],{"class":75,"line":344},[73,6333,4953],{"class":79},[73,6335,6189],{"class":116},[73,6337,6338],{"class":79},"attributes",[73,6340,977],{"class":116},[73,6342,6343],{"class":83},"'disabled'",[73,6345,6276],{"class":116},[73,6347,6348],{"class":79},"toBeDefined",[73,6350,1154],{"class":116},[73,6352,6353],{"class":75,"line":349},[73,6354,997],{"class":116},[73,6356,6357],{"class":75,"line":354},[73,6358,130],{"emptyLinePlaceholder":129},[73,6360,6361,6363,6365,6368,6370,6372],{"class":75,"line":363},[73,6362,4920],{"class":79},[73,6364,977],{"class":116},[73,6366,6367],{"class":83},"'shows loading spinner when loading'",[73,6369,983],{"class":116},[73,6371,632],{"class":631},[73,6373,268],{"class":116},[73,6375,6376,6378,6380,6382,6384],{"class":75,"line":372},[73,6377,950],{"class":631},[73,6379,6160],{"class":87},[73,6381,956],{"class":631},[73,6383,6165],{"class":79},[73,6385,6168],{"class":116},[73,6387,6388,6391,6393],{"class":75,"line":381},[73,6389,6390],{"class":116}," props: { loading: ",[73,6392,437],{"class":87},[73,6394,307],{"class":116},[73,6396,6397],{"class":75,"line":392},[73,6398,997],{"class":116},[73,6400,6401,6403,6405,6408,6410,6413,6415,6418,6420,6422,6424,6426],{"class":75,"line":397},[73,6402,4953],{"class":79},[73,6404,6189],{"class":116},[73,6406,6407],{"class":79},"find",[73,6409,977],{"class":116},[73,6411,6412],{"class":83},"'[data-testid=\"spinner\"]'",[73,6414,2608],{"class":116},[73,6416,6417],{"class":79},"exists",[73,6419,6195],{"class":116},[73,6421,4959],{"class":79},[73,6423,977],{"class":116},[73,6425,437],{"class":87},[73,6427,1370],{"class":116},[73,6429,6430],{"class":75,"line":403},[73,6431,997],{"class":116},[73,6433,6434],{"class":75,"line":408},[73,6435,130],{"emptyLinePlaceholder":129},[73,6437,6438,6440,6442,6445,6447,6449],{"class":75,"line":416},[73,6439,4920],{"class":79},[73,6441,977],{"class":116},[73,6443,6444],{"class":83},"'applies correct variant classes'",[73,6446,983],{"class":116},[73,6448,632],{"class":631},[73,6450,268],{"class":116},[73,6452,6453,6455,6457,6459,6461],{"class":75,"line":429},[73,6454,950],{"class":631},[73,6456,6160],{"class":87},[73,6458,956],{"class":631},[73,6460,6165],{"class":79},[73,6462,6168],{"class":116},[73,6464,6465,6468,6471],{"class":75,"line":442},[73,6466,6467],{"class":116}," props: { variant: ",[73,6469,6470],{"class":83},"'danger'",[73,6472,307],{"class":116},[73,6474,6475],{"class":75,"line":455},[73,6476,997],{"class":116},[73,6478,6479,6481,6483,6486,6488,6491,6493,6496],{"class":75,"line":460},[73,6480,4953],{"class":79},[73,6482,6189],{"class":116},[73,6484,6485],{"class":79},"classes",[73,6487,6195],{"class":116},[73,6489,6490],{"class":79},"toContain",[73,6492,977],{"class":116},[73,6494,6495],{"class":83},"'bg-red-600'",[73,6497,1370],{"class":116},[73,6499,6500],{"class":75,"line":468},[73,6501,997],{"class":116},[73,6503,6504],{"class":75,"line":480},[73,6505,1379],{"class":116},[15,6507,6508,6509,6512,6513,2425],{},"For components that use Pinia, Nuxt composables, or routing, use the ",[59,6510,6511],{},"mountSuspense"," helper from ",[59,6514,4737],{},[64,6516,6518],{"className":97,"code":6517,"language":99,"meta":69,"style":69},"import { mountSuspense } from '@nuxt/test-utils/runtime'\n\nIt('shows user name from store', async () => {\n const wrapper = await mountSuspense(UserProfile, {\n global: {\n plugins: [createTestingPinia({\n initialState: {\n user: { user: { id: '1', name: 'James Ross' } },\n },\n })],\n },\n })\n expect(wrapper.text()).toContain('James Ross')\n})\n",[59,6519,6520,6532,6536,6556,6572,6577,6587,6592,6607,6611,6616,6620,6624,6642],{"__ignoreMap":69},[73,6521,6522,6524,6527,6529],{"class":75,"line":76},[73,6523,2143],{"class":631},[73,6525,6526],{"class":116}," { mountSuspense } ",[73,6528,2149],{"class":631},[73,6530,6531],{"class":83}," '@nuxt/test-utils/runtime'\n",[73,6533,6534],{"class":75,"line":110},[73,6535,130],{"emptyLinePlaceholder":129},[73,6537,6538,6541,6543,6546,6548,6550,6552,6554],{"class":75,"line":126},[73,6539,6540],{"class":79},"It",[73,6542,977],{"class":116},[73,6544,6545],{"class":83},"'shows user name from store'",[73,6547,710],{"class":116},[73,6549,1996],{"class":631},[73,6551,5401],{"class":116},[73,6553,632],{"class":631},[73,6555,268],{"class":116},[73,6557,6558,6560,6562,6564,6566,6569],{"class":75,"line":133},[73,6559,950],{"class":631},[73,6561,6160],{"class":87},[73,6563,956],{"class":631},[73,6565,1401],{"class":631},[73,6567,6568],{"class":79}," mountSuspense",[73,6570,6571],{"class":116},"(UserProfile, {\n",[73,6573,6574],{"class":75,"line":142},[73,6575,6576],{"class":116}," global: {\n",[73,6578,6579,6582,6585],{"class":75,"line":157},[73,6580,6581],{"class":116}," plugins: [",[73,6583,6584],{"class":79},"createTestingPinia",[73,6586,1336],{"class":116},[73,6588,6589],{"class":75,"line":165},[73,6590,6591],{"class":116}," initialState: {\n",[73,6593,6594,6597,6599,6601,6604],{"class":75,"line":178},[73,6595,6596],{"class":116}," user: { user: { id: ",[73,6598,5425],{"class":83},[73,6600,5795],{"class":116},[73,6602,6603],{"class":83},"'James Ross'",[73,6605,6606],{"class":116}," } },\n",[73,6608,6609],{"class":75,"line":191},[73,6610,307],{"class":116},[73,6612,6613],{"class":75,"line":204},[73,6614,6615],{"class":116}," })],\n",[73,6617,6618],{"class":75,"line":217},[73,6619,307],{"class":116},[73,6621,6622],{"class":75,"line":230},[73,6623,997],{"class":116},[73,6625,6626,6628,6630,6632,6634,6636,6638,6640],{"class":75,"line":243},[73,6627,4953],{"class":79},[73,6629,6189],{"class":116},[73,6631,6192],{"class":79},[73,6633,6195],{"class":116},[73,6635,6490],{"class":79},[73,6637,977],{"class":116},[73,6639,6603],{"class":83},[73,6641,1370],{"class":116},[73,6643,6644],{"class":75,"line":256},[73,6645,1379],{"class":116},[22,6647,6649],{"id":6648},"testing-server-routes","Testing Server Routes",[15,6651,6652],{},"Test your Nitro API routes with the test server utilities:",[64,6654,6656],{"className":97,"code":6655,"language":99,"meta":69,"style":69},"// server/api/__tests__/users.test.ts\nimport { describe, it, expect } from 'vitest'\nimport { setup, $fetch, createError } from '@nuxt/test-utils'\n\nDescribe('Users API', async () => {\n await setup({ server: true })\n\n it('GET /api/users returns paginated list', async () => {\n const result = await $fetch('/api/users')\n expect(result).toHaveProperty('data')\n expect(result).toHaveProperty('pagination')\n expect(Array.isArray(result.data)).toBe(true)\n })\n\n it('POST /api/users validates input', async () => {\n await expect(\n $fetch('/api/users', {\n method: 'POST',\n body: { email: 'invalid' },\n })\n ).rejects.toMatchObject({ status: 422 })\n })\n})\n",[59,6657,6658,6663,6673,6685,6689,6708,6721,6725,6744,6764,6781,6796,6817,6821,6825,6844,6852,6862,6870,6880,6884,6900,6904],{"__ignoreMap":69},[73,6659,6660],{"class":75,"line":76},[73,6661,6662],{"class":106},"// server/api/__tests__/users.test.ts\n",[73,6664,6665,6667,6669,6671],{"class":75,"line":110},[73,6666,2143],{"class":631},[73,6668,4890],{"class":116},[73,6670,2149],{"class":631},[73,6672,4895],{"class":83},[73,6674,6675,6677,6680,6682],{"class":75,"line":126},[73,6676,2143],{"class":631},[73,6678,6679],{"class":116}," { setup, $fetch, createError } ",[73,6681,2149],{"class":631},[73,6683,6684],{"class":83}," '@nuxt/test-utils'\n",[73,6686,6687],{"class":75,"line":133},[73,6688,130],{"emptyLinePlaceholder":129},[73,6690,6691,6693,6695,6698,6700,6702,6704,6706],{"class":75,"line":142},[73,6692,4904],{"class":79},[73,6694,977],{"class":116},[73,6696,6697],{"class":83},"'Users API'",[73,6699,710],{"class":116},[73,6701,1996],{"class":631},[73,6703,5401],{"class":116},[73,6705,632],{"class":631},[73,6707,268],{"class":116},[73,6709,6710,6712,6714,6717,6719],{"class":75,"line":157},[73,6711,1401],{"class":631},[73,6713,1122],{"class":79},[73,6715,6716],{"class":116},"({ server: ",[73,6718,437],{"class":87},[73,6720,997],{"class":116},[73,6722,6723],{"class":75,"line":165},[73,6724,130],{"emptyLinePlaceholder":129},[73,6726,6727,6729,6731,6734,6736,6738,6740,6742],{"class":75,"line":178},[73,6728,4920],{"class":79},[73,6730,977],{"class":116},[73,6732,6733],{"class":83},"'GET /api/users returns paginated list'",[73,6735,710],{"class":116},[73,6737,1996],{"class":631},[73,6739,5401],{"class":116},[73,6741,632],{"class":631},[73,6743,268],{"class":116},[73,6745,6746,6748,6751,6753,6755,6757,6759,6762],{"class":75,"line":191},[73,6747,950],{"class":631},[73,6749,6750],{"class":87}," result",[73,6752,956],{"class":631},[73,6754,1401],{"class":631},[73,6756,2088],{"class":79},[73,6758,977],{"class":116},[73,6760,6761],{"class":83},"'/api/users'",[73,6763,1370],{"class":116},[73,6765,6766,6768,6771,6774,6776,6779],{"class":75,"line":204},[73,6767,4953],{"class":79},[73,6769,6770],{"class":116},"(result).",[73,6772,6773],{"class":79},"toHaveProperty",[73,6775,977],{"class":116},[73,6777,6778],{"class":83},"'data'",[73,6780,1370],{"class":116},[73,6782,6783,6785,6787,6789,6791,6794],{"class":75,"line":217},[73,6784,4953],{"class":79},[73,6786,6770],{"class":116},[73,6788,6773],{"class":79},[73,6790,977],{"class":116},[73,6792,6793],{"class":83},"'pagination'",[73,6795,1370],{"class":116},[73,6797,6798,6800,6803,6806,6809,6811,6813,6815],{"class":75,"line":230},[73,6799,4953],{"class":79},[73,6801,6802],{"class":116},"(Array.",[73,6804,6805],{"class":79},"isArray",[73,6807,6808],{"class":116},"(result.data)).",[73,6810,4959],{"class":79},[73,6812,977],{"class":116},[73,6814,437],{"class":87},[73,6816,1370],{"class":116},[73,6818,6819],{"class":75,"line":243},[73,6820,997],{"class":116},[73,6822,6823],{"class":75,"line":256},[73,6824,130],{"emptyLinePlaceholder":129},[73,6826,6827,6829,6831,6834,6836,6838,6840,6842],{"class":75,"line":265},[73,6828,4920],{"class":79},[73,6830,977],{"class":116},[73,6832,6833],{"class":83},"'POST /api/users validates input'",[73,6835,710],{"class":116},[73,6837,1996],{"class":631},[73,6839,5401],{"class":116},[73,6841,632],{"class":631},[73,6843,268],{"class":116},[73,6845,6846,6848,6850],{"class":75,"line":271},[73,6847,1401],{"class":631},[73,6849,4953],{"class":79},[73,6851,2060],{"class":116},[73,6853,6854,6856,6858,6860],{"class":75,"line":282},[73,6855,2088],{"class":79},[73,6857,977],{"class":116},[73,6859,6761],{"class":83},[73,6861,2096],{"class":116},[73,6863,6864,6866,6868],{"class":75,"line":293},[73,6865,2101],{"class":116},[73,6867,2104],{"class":83},[73,6869,154],{"class":116},[73,6871,6872,6875,6878],{"class":75,"line":304},[73,6873,6874],{"class":116}," body: { email: ",[73,6876,6877],{"class":83},"'invalid'",[73,6879,307],{"class":116},[73,6881,6882],{"class":75,"line":310},[73,6883,997],{"class":116},[73,6885,6886,6889,6892,6895,6898],{"class":75,"line":315},[73,6887,6888],{"class":116}," ).rejects.",[73,6890,6891],{"class":79},"toMatchObject",[73,6893,6894],{"class":116},"({ status: ",[73,6896,6897],{"class":87},"422",[73,6899,997],{"class":116},[73,6901,6902],{"class":75,"line":325},[73,6903,997],{"class":116},[73,6905,6906],{"class":75,"line":335},[73,6907,1379],{"class":116},[22,6909,6911],{"id":6910},"e2e-testing-with-playwright","E2E Testing With Playwright",[15,6913,6914],{},"Playwright tests run against a real browser and a real running application:",[64,6916,6918],{"className":97,"code":6917,"language":99,"meta":69,"style":69},"// playwright.config.ts\nimport { defineConfig } from '@playwright/test'\n\nExport default defineConfig({\n testDir: './e2e',\n webServer: {\n command: 'npm run dev',\n port: 3000,\n reuseExistingServer: !process.env.CI,\n },\n use: {\n baseURL: 'http://localhost:3000',\n screenshot: 'only-on-failure',\n video: 'retain-on-failure',\n },\n projects: [\n { name: 'chromium', use: { browserName: 'chromium' } },\n { name: 'mobile', use: { ...devices['iPhone 14'] } },\n ],\n})\n",[59,6919,6920,6925,6937,6941,6952,6962,6967,6977,6987,7002,7006,7011,7021,7031,7041,7045,7050,7065,7087,7091],{"__ignoreMap":69},[73,6921,6922],{"class":75,"line":76},[73,6923,6924],{"class":106},"// playwright.config.ts\n",[73,6926,6927,6929,6932,6934],{"class":75,"line":110},[73,6928,2143],{"class":631},[73,6930,6931],{"class":116}," { defineConfig } ",[73,6933,2149],{"class":631},[73,6935,6936],{"class":83}," '@playwright/test'\n",[73,6938,6939],{"class":75,"line":126},[73,6940,130],{"emptyLinePlaceholder":129},[73,6942,6943,6945,6947,6950],{"class":75,"line":133},[73,6944,2208],{"class":116},[73,6946,2211],{"class":631},[73,6948,6949],{"class":79}," defineConfig",[73,6951,1336],{"class":116},[73,6953,6954,6957,6960],{"class":75,"line":142},[73,6955,6956],{"class":116}," testDir: ",[73,6958,6959],{"class":83},"'./e2e'",[73,6961,154],{"class":116},[73,6963,6964],{"class":75,"line":157},[73,6965,6966],{"class":116}," webServer: {\n",[73,6968,6969,6972,6975],{"class":75,"line":165},[73,6970,6971],{"class":116}," command: ",[73,6973,6974],{"class":83},"'npm run dev'",[73,6976,154],{"class":116},[73,6978,6979,6982,6985],{"class":75,"line":178},[73,6980,6981],{"class":116}," port: ",[73,6983,6984],{"class":87},"3000",[73,6986,154],{"class":116},[73,6988,6989,6992,6994,6997,7000],{"class":75,"line":191},[73,6990,6991],{"class":116}," reuseExistingServer: ",[73,6993,1820],{"class":631},[73,6995,6996],{"class":116},"process.env.",[73,6998,6999],{"class":87},"CI",[73,7001,154],{"class":116},[73,7003,7004],{"class":75,"line":204},[73,7005,307],{"class":116},[73,7007,7008],{"class":75,"line":217},[73,7009,7010],{"class":116}," use: {\n",[73,7012,7013,7016,7019],{"class":75,"line":230},[73,7014,7015],{"class":116}," baseURL: ",[73,7017,7018],{"class":83},"'http://localhost:3000'",[73,7020,154],{"class":116},[73,7022,7023,7026,7029],{"class":75,"line":243},[73,7024,7025],{"class":116}," screenshot: ",[73,7027,7028],{"class":83},"'only-on-failure'",[73,7030,154],{"class":116},[73,7032,7033,7036,7039],{"class":75,"line":256},[73,7034,7035],{"class":116}," video: ",[73,7037,7038],{"class":83},"'retain-on-failure'",[73,7040,154],{"class":116},[73,7042,7043],{"class":75,"line":265},[73,7044,307],{"class":116},[73,7046,7047],{"class":75,"line":271},[73,7048,7049],{"class":116}," projects: [\n",[73,7051,7052,7055,7058,7061,7063],{"class":75,"line":282},[73,7053,7054],{"class":116}," { name: ",[73,7056,7057],{"class":83},"'chromium'",[73,7059,7060],{"class":116},", use: { browserName: ",[73,7062,7057],{"class":83},[73,7064,6606],{"class":116},[73,7066,7067,7069,7072,7075,7078,7081,7084],{"class":75,"line":293},[73,7068,7054],{"class":116},[73,7070,7071],{"class":83},"'mobile'",[73,7073,7074],{"class":116},", use: { ",[73,7076,7077],{"class":631},"...",[73,7079,7080],{"class":116},"devices[",[73,7082,7083],{"class":83},"'iPhone 14'",[73,7085,7086],{"class":116},"] } },\n",[73,7088,7089],{"class":75,"line":304},[73,7090,400],{"class":116},[73,7092,7093],{"class":75,"line":310},[73,7094,1379],{"class":116},[64,7096,7098],{"className":97,"code":7097,"language":99,"meta":69,"style":69},"// e2e/auth.spec.ts\nimport { test, expect } from '@playwright/test'\n\nTest('user can log in', async ({ page }) => {\n await page.goto('/login')\n\n await page.getByLabel('Email').fill('test@example.com')\n await page.getByLabel('Password').fill('password123')\n await page.getByRole('button', { name: 'Log in' }).click()\n\n await expect(page).toHaveURL('/dashboard')\n await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()\n})\n\nTest('shows error for invalid credentials', async ({ page }) => {\n await page.goto('/login')\n\n await page.getByLabel('Email').fill('wrong@example.com')\n await page.getByLabel('Password').fill('wrongpassword')\n await page.getByRole('button', { name: 'Log in' }).click()\n\n await expect(page.getByRole('alert')).toContainText('Invalid credentials')\n})\n",[59,7099,7100,7105,7116,7120,7146,7163,7167,7193,7217,7245,7249,7268,7297,7301,7305,7328,7342,7346,7369,7392,7414,7418,7445],{"__ignoreMap":69},[73,7101,7102],{"class":75,"line":76},[73,7103,7104],{"class":106},"// e2e/auth.spec.ts\n",[73,7106,7107,7109,7112,7114],{"class":75,"line":110},[73,7108,2143],{"class":631},[73,7110,7111],{"class":116}," { test, expect } ",[73,7113,2149],{"class":631},[73,7115,6936],{"class":83},[73,7117,7118],{"class":75,"line":126},[73,7119,130],{"emptyLinePlaceholder":129},[73,7121,7122,7125,7127,7130,7132,7134,7137,7140,7142,7144],{"class":75,"line":133},[73,7123,7124],{"class":79},"Test",[73,7126,977],{"class":116},[73,7128,7129],{"class":83},"'user can log in'",[73,7131,710],{"class":116},[73,7133,1996],{"class":631},[73,7135,7136],{"class":116}," ({ ",[73,7138,7139],{"class":624},"page",[73,7141,628],{"class":116},[73,7143,632],{"class":631},[73,7145,268],{"class":116},[73,7147,7148,7150,7153,7156,7158,7161],{"class":75,"line":142},[73,7149,1401],{"class":631},[73,7151,7152],{"class":116}," page.",[73,7154,7155],{"class":79},"goto",[73,7157,977],{"class":116},[73,7159,7160],{"class":83},"'/login'",[73,7162,1370],{"class":116},[73,7164,7165],{"class":75,"line":157},[73,7166,130],{"emptyLinePlaceholder":129},[73,7168,7169,7171,7173,7176,7178,7181,7183,7186,7188,7191],{"class":75,"line":165},[73,7170,1401],{"class":631},[73,7172,7152],{"class":116},[73,7174,7175],{"class":79},"getByLabel",[73,7177,977],{"class":116},[73,7179,7180],{"class":83},"'Email'",[73,7182,2608],{"class":116},[73,7184,7185],{"class":79},"fill",[73,7187,977],{"class":116},[73,7189,7190],{"class":83},"'test@example.com'",[73,7192,1370],{"class":116},[73,7194,7195,7197,7199,7201,7203,7206,7208,7210,7212,7215],{"class":75,"line":178},[73,7196,1401],{"class":631},[73,7198,7152],{"class":116},[73,7200,7175],{"class":79},[73,7202,977],{"class":116},[73,7204,7205],{"class":83},"'Password'",[73,7207,2608],{"class":116},[73,7209,7185],{"class":79},[73,7211,977],{"class":116},[73,7213,7214],{"class":83},"'password123'",[73,7216,1370],{"class":116},[73,7218,7219,7221,7223,7226,7228,7231,7234,7237,7240,7243],{"class":75,"line":191},[73,7220,1401],{"class":631},[73,7222,7152],{"class":116},[73,7224,7225],{"class":79},"getByRole",[73,7227,977],{"class":116},[73,7229,7230],{"class":83},"'button'",[73,7232,7233],{"class":116},", { name: ",[73,7235,7236],{"class":83},"'Log in'",[73,7238,7239],{"class":116}," }).",[73,7241,7242],{"class":79},"click",[73,7244,1154],{"class":116},[73,7246,7247],{"class":75,"line":204},[73,7248,130],{"emptyLinePlaceholder":129},[73,7250,7251,7253,7255,7258,7261,7263,7266],{"class":75,"line":217},[73,7252,1401],{"class":631},[73,7254,4953],{"class":79},[73,7256,7257],{"class":116},"(page).",[73,7259,7260],{"class":79},"toHaveURL",[73,7262,977],{"class":116},[73,7264,7265],{"class":83},"'/dashboard'",[73,7267,1370],{"class":116},[73,7269,7270,7272,7274,7277,7279,7281,7284,7286,7289,7292,7295],{"class":75,"line":230},[73,7271,1401],{"class":631},[73,7273,4953],{"class":79},[73,7275,7276],{"class":116},"(page.",[73,7278,7225],{"class":79},[73,7280,977],{"class":116},[73,7282,7283],{"class":83},"'heading'",[73,7285,7233],{"class":116},[73,7287,7288],{"class":83},"'Dashboard'",[73,7290,7291],{"class":116}," })).",[73,7293,7294],{"class":79},"toBeVisible",[73,7296,1154],{"class":116},[73,7298,7299],{"class":75,"line":243},[73,7300,1379],{"class":116},[73,7302,7303],{"class":75,"line":256},[73,7304,130],{"emptyLinePlaceholder":129},[73,7306,7307,7309,7311,7314,7316,7318,7320,7322,7324,7326],{"class":75,"line":265},[73,7308,7124],{"class":79},[73,7310,977],{"class":116},[73,7312,7313],{"class":83},"'shows error for invalid credentials'",[73,7315,710],{"class":116},[73,7317,1996],{"class":631},[73,7319,7136],{"class":116},[73,7321,7139],{"class":624},[73,7323,628],{"class":116},[73,7325,632],{"class":631},[73,7327,268],{"class":116},[73,7329,7330,7332,7334,7336,7338,7340],{"class":75,"line":271},[73,7331,1401],{"class":631},[73,7333,7152],{"class":116},[73,7335,7155],{"class":79},[73,7337,977],{"class":116},[73,7339,7160],{"class":83},[73,7341,1370],{"class":116},[73,7343,7344],{"class":75,"line":282},[73,7345,130],{"emptyLinePlaceholder":129},[73,7347,7348,7350,7352,7354,7356,7358,7360,7362,7364,7367],{"class":75,"line":293},[73,7349,1401],{"class":631},[73,7351,7152],{"class":116},[73,7353,7175],{"class":79},[73,7355,977],{"class":116},[73,7357,7180],{"class":83},[73,7359,2608],{"class":116},[73,7361,7185],{"class":79},[73,7363,977],{"class":116},[73,7365,7366],{"class":83},"'wrong@example.com'",[73,7368,1370],{"class":116},[73,7370,7371,7373,7375,7377,7379,7381,7383,7385,7387,7390],{"class":75,"line":304},[73,7372,1401],{"class":631},[73,7374,7152],{"class":116},[73,7376,7175],{"class":79},[73,7378,977],{"class":116},[73,7380,7205],{"class":83},[73,7382,2608],{"class":116},[73,7384,7185],{"class":79},[73,7386,977],{"class":116},[73,7388,7389],{"class":83},"'wrongpassword'",[73,7391,1370],{"class":116},[73,7393,7394,7396,7398,7400,7402,7404,7406,7408,7410,7412],{"class":75,"line":310},[73,7395,1401],{"class":631},[73,7397,7152],{"class":116},[73,7399,7225],{"class":79},[73,7401,977],{"class":116},[73,7403,7230],{"class":83},[73,7405,7233],{"class":116},[73,7407,7236],{"class":83},[73,7409,7239],{"class":116},[73,7411,7242],{"class":79},[73,7413,1154],{"class":116},[73,7415,7416],{"class":75,"line":315},[73,7417,130],{"emptyLinePlaceholder":129},[73,7419,7420,7422,7424,7426,7428,7430,7433,7435,7438,7440,7443],{"class":75,"line":325},[73,7421,1401],{"class":631},[73,7423,4953],{"class":79},[73,7425,7276],{"class":116},[73,7427,7225],{"class":79},[73,7429,977],{"class":116},[73,7431,7432],{"class":83},"'alert'",[73,7434,6276],{"class":116},[73,7436,7437],{"class":79},"toContainText",[73,7439,977],{"class":116},[73,7441,7442],{"class":83},"'Invalid credentials'",[73,7444,1370],{"class":116},[73,7446,7447],{"class":75,"line":335},[73,7448,1379],{"class":116},[22,7450,7452],{"id":7451},"ci-integration","CI Integration",[15,7454,7455],{},"Add tests to your CI pipeline (GitHub Actions):",[64,7457,7461],{"className":7458,"code":7459,"language":7460,"meta":69,"style":69},"language-yaml shiki shiki-themes github-dark","# .github/workflows/test.yml\nname: Tests\non: [push, pull_request]\n\nJobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with: { node-version: '20' }\n - run: npm ci\n - run: npm run test:coverage\n - run: npx playwright install --with-deps chromium\n - run: npm run test:e2e\n","yaml",[59,7462,7463,7468,7478,7496,7500,7508,7515,7525,7532,7545,7556,7574,7586,7597,7608],{"__ignoreMap":69},[73,7464,7465],{"class":75,"line":76},[73,7466,7467],{"class":106},"# .github/workflows/test.yml\n",[73,7469,7470,7473,7475],{"class":75,"line":110},[73,7471,7472],{"class":1118},"name",[73,7474,148],{"class":116},[73,7476,7477],{"class":83},"Tests\n",[73,7479,7480,7483,7485,7488,7490,7493],{"class":75,"line":126},[73,7481,7482],{"class":87},"on",[73,7484,117],{"class":116},[73,7486,7487],{"class":83},"push",[73,7489,710],{"class":116},[73,7491,7492],{"class":83},"pull_request",[73,7494,7495],{"class":116},"]\n",[73,7497,7498],{"class":75,"line":133},[73,7499,130],{"emptyLinePlaceholder":129},[73,7501,7502,7505],{"class":75,"line":142},[73,7503,7504],{"class":1118},"Jobs",[73,7506,7507],{"class":116},":\n",[73,7509,7510,7513],{"class":75,"line":157},[73,7511,7512],{"class":1118}," test",[73,7514,7507],{"class":116},[73,7516,7517,7520,7522],{"class":75,"line":165},[73,7518,7519],{"class":1118}," runs-on",[73,7521,148],{"class":116},[73,7523,7524],{"class":83},"ubuntu-latest\n",[73,7526,7527,7530],{"class":75,"line":178},[73,7528,7529],{"class":1118}," steps",[73,7531,7507],{"class":116},[73,7533,7534,7537,7540,7542],{"class":75,"line":191},[73,7535,7536],{"class":116}," - ",[73,7538,7539],{"class":1118},"uses",[73,7541,148],{"class":116},[73,7543,7544],{"class":83},"actions/checkout@v4\n",[73,7546,7547,7549,7551,7553],{"class":75,"line":204},[73,7548,7536],{"class":116},[73,7550,7539],{"class":1118},[73,7552,148],{"class":116},[73,7554,7555],{"class":83},"actions/setup-node@v4\n",[73,7557,7558,7561,7564,7567,7569,7572],{"class":75,"line":217},[73,7559,7560],{"class":1118}," with",[73,7562,7563],{"class":116},": { ",[73,7565,7566],{"class":1118},"node-version",[73,7568,148],{"class":116},[73,7570,7571],{"class":83},"'20'",[73,7573,1889],{"class":116},[73,7575,7576,7578,7581,7583],{"class":75,"line":230},[73,7577,7536],{"class":116},[73,7579,7580],{"class":1118},"run",[73,7582,148],{"class":116},[73,7584,7585],{"class":83},"npm ci\n",[73,7587,7588,7590,7592,7594],{"class":75,"line":243},[73,7589,7536],{"class":116},[73,7591,7580],{"class":1118},[73,7593,148],{"class":116},[73,7595,7596],{"class":83},"npm run test:coverage\n",[73,7598,7599,7601,7603,7605],{"class":75,"line":256},[73,7600,7536],{"class":116},[73,7602,7580],{"class":1118},[73,7604,148],{"class":116},[73,7606,7607],{"class":83},"npx playwright install --with-deps chromium\n",[73,7609,7610,7612,7614,7616],{"class":75,"line":265},[73,7611,7536],{"class":116},[73,7613,7580],{"class":1118},[73,7615,148],{"class":116},[73,7617,7618],{"class":83},"npm run test:e2e\n",[15,7620,7621],{},"Testing is not optional for applications that matter. The setup investment pays back every time you refactor a composable confidently, every time CI catches a regression before it reaches production, and every time you hand off a codebase to another developer who can read tests to understand intent.",[2326,7623],{},[15,7625,7626,7627,2274],{},"Want help setting up a complete testing strategy for your Nuxt application, or a code review of your existing test coverage? Book a call: ",[2332,7628,2337],{"href":2334,"rel":7629},[2336],[2326,7631],{},[22,7633,2343],{"id":2342},[2304,7635,7636,7642,7646,7650],{},[2307,7637,7638],{},[2332,7639,7641],{"href":7640},"/blog/tailwind-css-nuxt-setup","Tailwind CSS with Nuxt: Setup, Configuration, and Best Practices",[2307,7643,7644],{},[2332,7645,2369],{"href":2368},[2307,7647,7648],{},[2332,7649,2351],{"href":2350},[2307,7651,7652],{},[2332,7653,7],{"href":2395},[2371,7655,7656],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":69,"searchDepth":126,"depth":126,"links":7658},[7659,7660,7661,7662,7663,7664,7665,7666,7667],{"id":4413,"depth":110,"text":4414},{"id":4441,"depth":110,"text":4442},{"id":4730,"depth":110,"text":4731},{"id":5581,"depth":110,"text":5582},{"id":6070,"depth":110,"text":6071},{"id":6648,"depth":110,"text":6649},{"id":6910,"depth":110,"text":6911},{"id":7451,"depth":110,"text":7452},{"id":2342,"depth":110,"text":2343},"A complete testing setup for Nuxt 3 and 4 — unit tests for composables and stores, component testing with Vue Test Library, and E2E tests with Playwright.",[7670,7671],"Nuxt testing","Vitest Nuxt",{},"/blog/nuxt-testing-vitest",{"title":4401,"description":7668},"blog/nuxt-testing-vitest",[2399,7677,7678],"Testing","Vitest","V9BXfh_Tky0fpaOj3S0ZzrB-N5irNKh3oLt8z0p_0xM",{"id":7681,"title":7682,"author":7683,"body":7684,"category":2385,"date":2386,"description":9418,"extension":2388,"featured":2389,"image":2390,"keywords":9419,"meta":9422,"navigation":129,"path":9423,"readTime":165,"seo":9424,"stem":9425,"tags":9426,"__hash__":9429},"blog/blog/nuxt-typescript-guide.md","TypeScript in Nuxt: Getting the Type Safety You Actually Want",{"name":9,"bio":10},{"type":12,"value":7685,"toc":9406},[7686,7689,7692,7696,7707,7797,7802,7827,7831,7838,7855,7858,7993,7997,8003,8034,8039,8117,8132,8136,8146,8298,8301,8423,8475,8478,8482,8485,8691,8694,8698,8701,8941,8945,8952,9101,9104,9108,9121,9136,9156,9169,9282,9286,9293,9325,9330,9363,9368,9371,9373,9379,9381,9383,9403],[15,7687,7688],{},"TypeScript support in Nuxt has come a long way. Early Nuxt 3 had rough edges — auto-imported composables would not be recognized by the type checker, component props from other libraries would not infer correctly, and getting the tsconfig right required trial and error. Most of those problems are solved in Nuxt 4, and the ones that remain have well-established workarounds.",[15,7690,7691],{},"This article walks through building a genuinely type-safe Nuxt application — not just adding TypeScript syntax, but having the type checker actually catch the bugs that matter.",[22,7693,7695],{"id":7694},"the-right-tsconfigjson","The Right tsconfig.json",[15,7697,7698,7699,7702,7703,7706],{},"Nuxt generates a ",[59,7700,7701],{},".nuxt/tsconfig.json"," that extends from your root config. Your root ",[59,7704,7705],{},"tsconfig.json"," should look like this:",[64,7708,7710],{"className":4657,"code":7709,"language":4659,"meta":69,"style":69},"{\n \"extends\": \"./.nuxt/tsconfig.json\",\n \"compilerOptions\": {\n \"strict\": true,\n \"noUnusedLocals\": true,\n \"noUnusedParameters\": true,\n \"exactOptionalPropertyTypes\": true,\n \"noFallthroughCasesInSwitch\": true\n }\n}\n",[59,7711,7712,7716,7728,7735,7746,7757,7768,7779,7789,7793],{"__ignoreMap":69},[73,7713,7714],{"class":75,"line":76},[73,7715,4666],{"class":116},[73,7717,7718,7721,7723,7726],{"class":75,"line":110},[73,7719,7720],{"class":87}," \"extends\"",[73,7722,148],{"class":116},[73,7724,7725],{"class":83},"\"./.nuxt/tsconfig.json\"",[73,7727,154],{"class":116},[73,7729,7730,7733],{"class":75,"line":126},[73,7731,7732],{"class":87}," \"compilerOptions\"",[73,7734,139],{"class":116},[73,7736,7737,7740,7742,7744],{"class":75,"line":133},[73,7738,7739],{"class":87}," \"strict\"",[73,7741,148],{"class":116},[73,7743,437],{"class":87},[73,7745,154],{"class":116},[73,7747,7748,7751,7753,7755],{"class":75,"line":142},[73,7749,7750],{"class":87}," \"noUnusedLocals\"",[73,7752,148],{"class":116},[73,7754,437],{"class":87},[73,7756,154],{"class":116},[73,7758,7759,7762,7764,7766],{"class":75,"line":157},[73,7760,7761],{"class":87}," \"noUnusedParameters\"",[73,7763,148],{"class":116},[73,7765,437],{"class":87},[73,7767,154],{"class":116},[73,7769,7770,7773,7775,7777],{"class":75,"line":165},[73,7771,7772],{"class":87}," \"exactOptionalPropertyTypes\"",[73,7774,148],{"class":116},[73,7776,437],{"class":87},[73,7778,154],{"class":116},[73,7780,7781,7784,7786],{"class":75,"line":178},[73,7782,7783],{"class":87}," \"noFallthroughCasesInSwitch\"",[73,7785,148],{"class":116},[73,7787,7788],{"class":87},"true\n",[73,7790,7791],{"class":75,"line":191},[73,7792,1889],{"class":116},[73,7794,7795],{"class":75,"line":204},[73,7796,1098],{"class":116},[15,7798,57,7799,7801],{},[59,7800,7701],{}," sets up the paths for auto-imports and Nuxt-specific type definitions. Extending it means you get those automatically.",[15,7803,7804,7805,7808,7809,710,7812,710,7815,7818,7819,7822,7823,7826],{},"Turn on ",[59,7806,7807],{},"strict"," mode. It enables a collection of checks that catch real bugs: ",[59,7810,7811],{},"strictNullChecks",[59,7813,7814],{},"strictFunctionTypes",[59,7816,7817],{},"noImplicitAny",". Projects that avoid strict mode to save time end up with types that lie — ",[59,7820,7821],{},"string | null"," becomes ",[59,7824,7825],{},"string",", errors get ignored, and the type checker becomes noise rather than signal.",[22,7828,7830],{"id":7829},"typed-auto-imports","Typed Auto-Imports",[15,7832,7833,7834,7837],{},"Nuxt auto-imports composables and components, which creates a TypeScript challenge: the types need to be available without explicit import statements. Nuxt handles this by generating a ",[59,7835,7836],{},".nuxt/imports.d.ts"," file with declarations for all auto-imported functions.",[15,7839,7840,7841,7844,7845,3291,7848,7851,7852,7854],{},"After running ",[59,7842,7843],{},"nuxt prepare"," (which runs on ",[59,7846,7847],{},"nuxt dev",[59,7849,7850],{},"nuxt build","), all auto-imported composables are typed. If you add a new composable and TypeScript does not recognize it immediately, run ",[59,7853,7843],{}," manually.",[15,7856,7857],{},"For custom composables, the type is inferred from the implementation:",[64,7859,7861],{"className":97,"code":7860,"language":99,"meta":69,"style":69},"// composables/useAuth.ts\nexport function useAuth() {\n const user = useState\u003CUser | null>('user', () => null)\n const isAuthenticated = computed(() => user.value !== null)\n\n return { user: readonly(user), isAuthenticated }\n}\n\n// In any component — fully typed without import:\nconst { user, isAuthenticated } = useAuth()\n// ^--- User | null ^--- boolean\n",[59,7862,7863,7868,7879,7913,7937,7941,7953,7957,7961,7966,7988],{"__ignoreMap":69},[73,7864,7865],{"class":75,"line":76},[73,7866,7867],{"class":106},"// composables/useAuth.ts\n",[73,7869,7870,7872,7874,7877],{"class":75,"line":110},[73,7871,936],{"class":631},[73,7873,939],{"class":631},[73,7875,7876],{"class":79}," useAuth",[73,7878,945],{"class":116},[73,7880,7881,7883,7886,7888,7891,7893,7896,7898,7900,7902,7905,7907,7909,7911],{"class":75,"line":126},[73,7882,950],{"class":631},[73,7884,7885],{"class":87}," user",[73,7887,956],{"class":631},[73,7889,7890],{"class":79}," useState",[73,7892,1115],{"class":116},[73,7894,7895],{"class":79},"User",[73,7897,1655],{"class":631},[73,7899,1658],{"class":87},[73,7901,1661],{"class":116},[73,7903,7904],{"class":83},"'user'",[73,7906,983],{"class":116},[73,7908,632],{"class":631},[73,7910,1658],{"class":87},[73,7912,1370],{"class":116},[73,7914,7915,7917,7920,7922,7924,7926,7928,7931,7933,7935],{"class":75,"line":133},[73,7916,950],{"class":631},[73,7918,7919],{"class":87}," isAuthenticated",[73,7921,956],{"class":631},[73,7923,4795],{"class":79},[73,7925,1033],{"class":116},[73,7927,632],{"class":631},[73,7929,7930],{"class":116}," user.value ",[73,7932,1929],{"class":631},[73,7934,1658],{"class":87},[73,7936,1370],{"class":116},[73,7938,7939],{"class":75,"line":142},[73,7940,130],{"emptyLinePlaceholder":129},[73,7942,7943,7945,7948,7950],{"class":75,"line":157},[73,7944,1084],{"class":631},[73,7946,7947],{"class":116}," { user: ",[73,7949,1090],{"class":79},[73,7951,7952],{"class":116},"(user), isAuthenticated }\n",[73,7954,7955],{"class":75,"line":165},[73,7956,1098],{"class":116},[73,7958,7959],{"class":75,"line":178},[73,7960,130],{"emptyLinePlaceholder":129},[73,7962,7963],{"class":75,"line":191},[73,7964,7965],{"class":106},"// In any component — fully typed without import:\n",[73,7967,7968,7970,7972,7975,7977,7980,7982,7984,7986],{"class":75,"line":204},[73,7969,1138],{"class":631},[73,7971,1141],{"class":116},[73,7973,7974],{"class":87},"user",[73,7976,710],{"class":116},[73,7978,7979],{"class":87},"isAuthenticated",[73,7981,1147],{"class":116},[73,7983,991],{"class":631},[73,7985,7876],{"class":79},[73,7987,1154],{"class":116},[73,7989,7990],{"class":75,"line":217},[73,7991,7992],{"class":106},"// ^--- User | null ^--- boolean\n",[22,7994,7996],{"id":7995},"typed-router","Typed Router",[15,7998,7999,8000,8002],{},"The typed router in Nuxt 4 generates route types from your ",[59,8001,3029],{}," directory. Enable it:",[64,8004,8006],{"className":97,"code":8005,"language":99,"meta":69,"style":69},"// nuxt.config.ts\nexperimental: {\n typedPages: true,\n}\n",[59,8007,8008,8012,8019,8030],{"__ignoreMap":69},[73,8009,8010],{"class":75,"line":76},[73,8011,107],{"class":106},[73,8013,8014,8017],{"class":75,"line":110},[73,8015,8016],{"class":79},"experimental",[73,8018,139],{"class":116},[73,8020,8021,8024,8026,8028],{"class":75,"line":126},[73,8022,8023],{"class":79}," typedPages",[73,8025,148],{"class":116},[73,8027,437],{"class":87},[73,8029,154],{"class":116},[73,8031,8032],{"class":75,"line":133},[73,8033,1098],{"class":116},[15,8035,7840,8036,8038],{},[59,8037,7843],{},", you get type-safe navigation:",[64,8040,8042],{"className":97,"code":8041,"language":99,"meta":69,"style":69},"// Typed navigateTo\nnavigateTo({ name: 'users-id', params: { id: '123' } })\n// ^^^^^^ TypeScript knows 'id' is required\n\n// Typed useRoute\nconst route = useRoute('users-id')\nroute.params.id // typed as string\nroute.params.nonexistent // TypeScript error\n",[59,8043,8044,8049,8069,8074,8078,8083,8101,8109],{"__ignoreMap":69},[73,8045,8046],{"class":75,"line":76},[73,8047,8048],{"class":106},"// Typed navigateTo\n",[73,8050,8051,8054,8057,8060,8063,8066],{"class":75,"line":110},[73,8052,8053],{"class":79},"navigateTo",[73,8055,8056],{"class":116},"({ name: ",[73,8058,8059],{"class":83},"'users-id'",[73,8061,8062],{"class":116},", params: { id: ",[73,8064,8065],{"class":83},"'123'",[73,8067,8068],{"class":116}," } })\n",[73,8070,8071],{"class":75,"line":126},[73,8072,8073],{"class":106},"// ^^^^^^ TypeScript knows 'id' is required\n",[73,8075,8076],{"class":75,"line":133},[73,8077,130],{"emptyLinePlaceholder":129},[73,8079,8080],{"class":75,"line":142},[73,8081,8082],{"class":106},"// Typed useRoute\n",[73,8084,8085,8087,8090,8092,8095,8097,8099],{"class":75,"line":157},[73,8086,1138],{"class":631},[73,8088,8089],{"class":87}," route",[73,8091,956],{"class":631},[73,8093,8094],{"class":79}," useRoute",[73,8096,977],{"class":116},[73,8098,8059],{"class":83},[73,8100,1370],{"class":116},[73,8102,8103,8106],{"class":75,"line":165},[73,8104,8105],{"class":116},"route.params.id ",[73,8107,8108],{"class":106},"// typed as string\n",[73,8110,8111,8114],{"class":75,"line":178},[73,8112,8113],{"class":116},"route.params.nonexistent ",[73,8115,8116],{"class":106},"// TypeScript error\n",[15,8118,8119,8120,8123,8124,8127,8128,8131],{},"This catches a whole class of routing bugs at compile time. If you rename a page from ",[59,8121,8122],{},"pages/users/[id].vue"," to ",[59,8125,8126],{},"pages/users/[userId].vue",", TypeScript will flag every call that still uses the old ",[59,8129,8130],{},"id"," param name.",[22,8133,8135],{"id":8134},"typed-api-calls","Typed API Calls",[15,8137,8138,8139,3291,8142,8145],{},"Nuxt's ",[59,8140,8141],{},"useFetch",[59,8143,8144],{},"$fetch"," accept a generic type parameter:",[64,8147,8149],{"className":97,"code":8148,"language":99,"meta":69,"style":69},"interface Post {\n id: string\n title: string\n content: string\n publishedAt: string\n}\n\n// Typed fetch\nconst { data: post } = await useFetch\u003CPost>('/api/posts/123')\npost.value?.title // string | undefined (handles null data state)\n\n// Typed $fetch\nconst posts = await $fetch\u003CPost[]>('/api/posts')\nposts[0].title // string\n",[59,8150,8151,8161,8171,8179,8188,8197,8201,8205,8210,8243,8251,8255,8260,8285],{"__ignoreMap":69},[73,8152,8153,8156,8159],{"class":75,"line":76},[73,8154,8155],{"class":631},"interface",[73,8157,8158],{"class":79}," Post",[73,8160,268],{"class":116},[73,8162,8163,8166,8168],{"class":75,"line":110},[73,8164,8165],{"class":624}," id",[73,8167,2425],{"class":631},[73,8169,8170],{"class":87}," string\n",[73,8172,8173,8175,8177],{"class":75,"line":126},[73,8174,2644],{"class":624},[73,8176,2425],{"class":631},[73,8178,8170],{"class":87},[73,8180,8181,8184,8186],{"class":75,"line":133},[73,8182,8183],{"class":624}," content",[73,8185,2425],{"class":631},[73,8187,8170],{"class":87},[73,8189,8190,8193,8195],{"class":75,"line":142},[73,8191,8192],{"class":624}," publishedAt",[73,8194,2425],{"class":631},[73,8196,8170],{"class":87},[73,8198,8199],{"class":75,"line":157},[73,8200,1098],{"class":116},[73,8202,8203],{"class":75,"line":165},[73,8204,130],{"emptyLinePlaceholder":129},[73,8206,8207],{"class":75,"line":178},[73,8208,8209],{"class":106},"// Typed fetch\n",[73,8211,8212,8214,8216,8218,8220,8222,8224,8226,8228,8231,8233,8236,8238,8241],{"class":75,"line":191},[73,8213,1138],{"class":631},[73,8215,1141],{"class":116},[73,8217,2571],{"class":624},[73,8219,148],{"class":116},[73,8221,2576],{"class":87},[73,8223,1147],{"class":116},[73,8225,991],{"class":631},[73,8227,1401],{"class":631},[73,8229,8230],{"class":79}," useFetch",[73,8232,1115],{"class":116},[73,8234,8235],{"class":79},"Post",[73,8237,1661],{"class":116},[73,8239,8240],{"class":83},"'/api/posts/123'",[73,8242,1370],{"class":116},[73,8244,8245,8248],{"class":75,"line":204},[73,8246,8247],{"class":116},"post.value?.title ",[73,8249,8250],{"class":106},"// string | undefined (handles null data state)\n",[73,8252,8253],{"class":75,"line":217},[73,8254,130],{"emptyLinePlaceholder":129},[73,8256,8257],{"class":75,"line":230},[73,8258,8259],{"class":106},"// Typed $fetch\n",[73,8261,8262,8264,8267,8269,8271,8273,8275,8277,8280,8283],{"class":75,"line":243},[73,8263,1138],{"class":631},[73,8265,8266],{"class":87}," posts",[73,8268,956],{"class":631},[73,8270,1401],{"class":631},[73,8272,2088],{"class":79},[73,8274,1115],{"class":116},[73,8276,8235],{"class":79},[73,8278,8279],{"class":116},"[]>(",[73,8281,8282],{"class":83},"'/api/posts'",[73,8284,1370],{"class":116},[73,8286,8287,8290,8292,8295],{"class":75,"line":256},[73,8288,8289],{"class":116},"posts[",[73,8291,4964],{"class":87},[73,8293,8294],{"class":116},"].title ",[73,8296,8297],{"class":106},"// string\n",[15,8299,8300],{},"For more rigorous type safety, validate API responses at runtime with Zod and derive the types from your schemas:",[64,8302,8304],{"className":97,"code":8303,"language":99,"meta":69,"style":69},"// types/post.ts\nimport { z } from 'zod'\n\nExport const PostSchema = z.object({\n id: z.string(),\n title: z.string(),\n content: z.string(),\n publishedAt: z.string().datetime(),\n})\n\nExport type Post = z.infer\u003Ctypeof PostSchema>\n",[59,8305,8306,8311,8323,8327,8346,8355,8364,8373,8388,8392,8396],{"__ignoreMap":69},[73,8307,8308],{"class":75,"line":76},[73,8309,8310],{"class":106},"// types/post.ts\n",[73,8312,8313,8315,8318,8320],{"class":75,"line":110},[73,8314,2143],{"class":631},[73,8316,8317],{"class":116}," { z } ",[73,8319,2149],{"class":631},[73,8321,8322],{"class":83}," 'zod'\n",[73,8324,8325],{"class":75,"line":126},[73,8326,130],{"emptyLinePlaceholder":129},[73,8328,8329,8331,8333,8336,8338,8341,8344],{"class":75,"line":133},[73,8330,2208],{"class":116},[73,8332,1138],{"class":631},[73,8334,8335],{"class":87}," PostSchema",[73,8337,956],{"class":631},[73,8339,8340],{"class":116}," z.",[73,8342,8343],{"class":79},"object",[73,8345,1336],{"class":116},[73,8347,8348,8351,8353],{"class":75,"line":142},[73,8349,8350],{"class":116}," id: z.",[73,8352,7825],{"class":79},[73,8354,2117],{"class":116},[73,8356,8357,8360,8362],{"class":75,"line":157},[73,8358,8359],{"class":116}," title: z.",[73,8361,7825],{"class":79},[73,8363,2117],{"class":116},[73,8365,8366,8369,8371],{"class":75,"line":165},[73,8367,8368],{"class":116}," content: z.",[73,8370,7825],{"class":79},[73,8372,2117],{"class":116},[73,8374,8375,8378,8380,8383,8386],{"class":75,"line":178},[73,8376,8377],{"class":116}," publishedAt: z.",[73,8379,7825],{"class":79},[73,8381,8382],{"class":116},"().",[73,8384,8385],{"class":79},"datetime",[73,8387,2117],{"class":116},[73,8389,8390],{"class":75,"line":191},[73,8391,1379],{"class":116},[73,8393,8394],{"class":75,"line":204},[73,8395,130],{"emptyLinePlaceholder":129},[73,8397,8398,8400,8403,8405,8407,8410,8412,8415,8417,8420],{"class":75,"line":217},[73,8399,2208],{"class":116},[73,8401,8402],{"class":631},"type",[73,8404,8158],{"class":79},[73,8406,956],{"class":631},[73,8408,8409],{"class":79}," z",[73,8411,2274],{"class":116},[73,8413,8414],{"class":79},"infer",[73,8416,1115],{"class":116},[73,8418,8419],{"class":631},"typeof",[73,8421,8422],{"class":116}," PostSchema>\n",[64,8424,8426],{"className":97,"code":8425,"language":99,"meta":69,"style":69},"// In your composable\nconst response = await $fetch('/api/posts/123')\nconst post = PostSchema.parse(response)\n// post is typed as Post and validated at runtime\n",[59,8427,8428,8433,8452,8470],{"__ignoreMap":69},[73,8429,8430],{"class":75,"line":76},[73,8431,8432],{"class":106},"// In your composable\n",[73,8434,8435,8437,8440,8442,8444,8446,8448,8450],{"class":75,"line":110},[73,8436,1138],{"class":631},[73,8438,8439],{"class":87}," response",[73,8441,956],{"class":631},[73,8443,1401],{"class":631},[73,8445,2088],{"class":79},[73,8447,977],{"class":116},[73,8449,8240],{"class":83},[73,8451,1370],{"class":116},[73,8453,8454,8456,8459,8461,8464,8467],{"class":75,"line":126},[73,8455,1138],{"class":631},[73,8457,8458],{"class":87}," post",[73,8460,956],{"class":631},[73,8462,8463],{"class":116}," PostSchema.",[73,8465,8466],{"class":79},"parse",[73,8468,8469],{"class":116},"(response)\n",[73,8471,8472],{"class":75,"line":133},[73,8473,8474],{"class":106},"// post is typed as Post and validated at runtime\n",[15,8476,8477],{},"The combination of TypeScript for compile-time safety and Zod for runtime validation means your API types actually match reality — not just what you hoped the API would return.",[22,8479,8481],{"id":8480},"typed-component-props","Typed Component Props",[15,8483,8484],{},"Use TypeScript interfaces for component props rather than the options-based validator syntax:",[64,8486,8488],{"className":97,"code":8487,"language":99,"meta":69,"style":69},"// Incorrect: runtime validation only, no TypeScript inference\nprops: {\n user: {\n type: Object as PropType\u003CUser>,\n required: true,\n },\n}\n\n// Correct: compile-time type checking\ninterface Props {\n user: User\n onSelect?: (user: User) => void\n size?: 'sm' | 'md' | 'lg'\n}\n\nConst props = defineProps\u003CProps>()\nconst emit = defineEmits\u003C{\n select: [user: User]\n close: []\n}>()\n",[59,8489,8490,8495,8502,8508,8527,8538,8542,8546,8550,8555,8564,8573,8597,8617,8621,8625,8643,8658,8676,8686],{"__ignoreMap":69},[73,8491,8492],{"class":75,"line":76},[73,8493,8494],{"class":106},"// Incorrect: runtime validation only, no TypeScript inference\n",[73,8496,8497,8500],{"class":75,"line":110},[73,8498,8499],{"class":79},"props",[73,8501,139],{"class":116},[73,8503,8504,8506],{"class":75,"line":126},[73,8505,7885],{"class":79},[73,8507,139],{"class":116},[73,8509,8510,8512,8515,8517,8520,8522,8524],{"class":75,"line":133},[73,8511,544],{"class":79},[73,8513,8514],{"class":116},": Object ",[73,8516,1742],{"class":631},[73,8518,8519],{"class":79}," PropType",[73,8521,1115],{"class":116},[73,8523,7895],{"class":79},[73,8525,8526],{"class":116},">,\n",[73,8528,8529,8532,8534,8536],{"class":75,"line":142},[73,8530,8531],{"class":79}," required",[73,8533,148],{"class":116},[73,8535,437],{"class":87},[73,8537,154],{"class":116},[73,8539,8540],{"class":75,"line":157},[73,8541,307],{"class":116},[73,8543,8544],{"class":75,"line":165},[73,8545,1098],{"class":116},[73,8547,8548],{"class":75,"line":178},[73,8549,130],{"emptyLinePlaceholder":129},[73,8551,8552],{"class":75,"line":191},[73,8553,8554],{"class":106},"// Correct: compile-time type checking\n",[73,8556,8557,8559,8562],{"class":75,"line":204},[73,8558,8155],{"class":631},[73,8560,8561],{"class":79}," Props",[73,8563,268],{"class":116},[73,8565,8566,8568,8570],{"class":75,"line":217},[73,8567,7885],{"class":624},[73,8569,2425],{"class":631},[73,8571,8572],{"class":79}," User\n",[73,8574,8575,8578,8581,8583,8585,8587,8590,8592,8594],{"class":75,"line":230},[73,8576,8577],{"class":79}," onSelect",[73,8579,8580],{"class":631},"?:",[73,8582,1817],{"class":116},[73,8584,7974],{"class":624},[73,8586,2425],{"class":631},[73,8588,8589],{"class":79}," User",[73,8591,1715],{"class":116},[73,8593,632],{"class":631},[73,8595,8596],{"class":87}," void\n",[73,8598,8599,8602,8604,8607,8609,8612,8614],{"class":75,"line":243},[73,8600,8601],{"class":624}," size",[73,8603,8580],{"class":631},[73,8605,8606],{"class":83}," 'sm'",[73,8608,1655],{"class":631},[73,8610,8611],{"class":83}," 'md'",[73,8613,1655],{"class":631},[73,8615,8616],{"class":83}," 'lg'\n",[73,8618,8619],{"class":75,"line":256},[73,8620,1098],{"class":116},[73,8622,8623],{"class":75,"line":265},[73,8624,130],{"emptyLinePlaceholder":129},[73,8626,8627,8630,8632,8635,8637,8640],{"class":75,"line":271},[73,8628,8629],{"class":116},"Const props ",[73,8631,991],{"class":631},[73,8633,8634],{"class":79}," defineProps",[73,8636,1115],{"class":116},[73,8638,8639],{"class":79},"Props",[73,8641,8642],{"class":116},">()\n",[73,8644,8645,8647,8650,8652,8655],{"class":75,"line":282},[73,8646,1138],{"class":631},[73,8648,8649],{"class":87}," emit",[73,8651,956],{"class":631},[73,8653,8654],{"class":79}," defineEmits",[73,8656,8657],{"class":116},"\u003C{\n",[73,8659,8660,8663,8665,8668,8670,8672,8674],{"class":75,"line":293},[73,8661,8662],{"class":624}," select",[73,8664,2425],{"class":631},[73,8666,8667],{"class":116}," [",[73,8669,7974],{"class":79},[73,8671,148],{"class":116},[73,8673,7895],{"class":79},[73,8675,7495],{"class":116},[73,8677,8678,8681,8683],{"class":75,"line":304},[73,8679,8680],{"class":624}," close",[73,8682,2425],{"class":631},[73,8684,8685],{"class":116}," []\n",[73,8687,8688],{"class":75,"line":310},[73,8689,8690],{"class":116},"}>()\n",[15,8692,8693],{},"The generic syntax provides better TypeScript inference and eliminates a lot of ceremony. The downside is no runtime validation — if you need that, keep your Zod schemas and validate in the composable layer rather than in component props.",[22,8695,8697],{"id":8696},"typed-pinia-stores","Typed Pinia Stores",[15,8699,8700],{},"The composable-style store infers types automatically:",[64,8702,8704],{"className":97,"code":8703,"language":99,"meta":69,"style":69},"// stores/cart.ts\nimport type { CartItem } from '~/types/cart'\n\nExport const useCartStore = defineStore('cart', () => {\n const items = ref\u003CCartItem[]>([])\n\n const total = computed(() =>\n items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)\n )\n\n function addItem(item: CartItem): void {\n // TypeScript enforces CartItem shape here\n }\n\n return { items: readonly(items), total, addItem }\n})\n\n// In components:\nconst cart = useCartStore()\ncart.items // readonly CartItem[]\ncart.total // number\ncart.addItem // (item: CartItem) => void\n",[59,8705,8706,8711,8725,8729,8753,8772,8776,8791,8832,8837,8841,8867,8872,8876,8880,8892,8896,8900,8905,8917,8925,8933],{"__ignoreMap":69},[73,8707,8708],{"class":75,"line":76},[73,8709,8710],{"class":106},"// stores/cart.ts\n",[73,8712,8713,8715,8717,8720,8722],{"class":75,"line":110},[73,8714,2143],{"class":631},[73,8716,544],{"class":631},[73,8718,8719],{"class":116}," { CartItem } ",[73,8721,2149],{"class":631},[73,8723,8724],{"class":83}," '~/types/cart'\n",[73,8726,8727],{"class":75,"line":126},[73,8728,130],{"emptyLinePlaceholder":129},[73,8730,8731,8733,8735,8737,8739,8742,8744,8747,8749,8751],{"class":75,"line":133},[73,8732,2208],{"class":116},[73,8734,1138],{"class":631},[73,8736,5710],{"class":87},[73,8738,956],{"class":631},[73,8740,8741],{"class":79}," defineStore",[73,8743,977],{"class":116},[73,8745,8746],{"class":83},"'cart'",[73,8748,983],{"class":116},[73,8750,632],{"class":631},[73,8752,268],{"class":116},[73,8754,8755,8757,8760,8762,8764,8766,8769],{"class":75,"line":142},[73,8756,950],{"class":631},[73,8758,8759],{"class":87}," items",[73,8761,956],{"class":631},[73,8763,959],{"class":79},[73,8765,1115],{"class":116},[73,8767,8768],{"class":79},"CartItem",[73,8770,8771],{"class":116},"[]>([])\n",[73,8773,8774],{"class":75,"line":157},[73,8775,130],{"emptyLinePlaceholder":129},[73,8777,8778,8780,8783,8785,8787,8789],{"class":75,"line":165},[73,8779,950],{"class":631},[73,8781,8782],{"class":87}," total",[73,8784,956],{"class":631},[73,8786,4795],{"class":79},[73,8788,1033],{"class":116},[73,8790,2595],{"class":631},[73,8792,8793,8796,8799,8802,8805,8807,8810,8812,8814,8817,8820,8823,8825,8828,8830],{"class":75,"line":178},[73,8794,8795],{"class":116}," items.value.",[73,8797,8798],{"class":79},"reduce",[73,8800,8801],{"class":116},"((",[73,8803,8804],{"class":624},"sum",[73,8806,710],{"class":116},[73,8808,8809],{"class":624},"item",[73,8811,1715],{"class":116},[73,8813,632],{"class":631},[73,8815,8816],{"class":116}," sum ",[73,8818,8819],{"class":631},"+",[73,8821,8822],{"class":116}," item.price ",[73,8824,4805],{"class":631},[73,8826,8827],{"class":116}," item.quantity, ",[73,8829,4964],{"class":87},[73,8831,1370],{"class":116},[73,8833,8834],{"class":75,"line":191},[73,8835,8836],{"class":116}," )\n",[73,8838,8839],{"class":75,"line":204},[73,8840,130],{"emptyLinePlaceholder":129},[73,8842,8843,8845,8848,8850,8852,8854,8857,8860,8862,8865],{"class":75,"line":217},[73,8844,939],{"class":631},[73,8846,8847],{"class":79}," addItem",[73,8849,977],{"class":116},[73,8851,8809],{"class":624},[73,8853,2425],{"class":631},[73,8855,8856],{"class":79}," CartItem",[73,8858,8859],{"class":116},")",[73,8861,2425],{"class":631},[73,8863,8864],{"class":87}," void",[73,8866,268],{"class":116},[73,8868,8869],{"class":75,"line":230},[73,8870,8871],{"class":106}," // TypeScript enforces CartItem shape here\n",[73,8873,8874],{"class":75,"line":243},[73,8875,1889],{"class":116},[73,8877,8878],{"class":75,"line":256},[73,8879,130],{"emptyLinePlaceholder":129},[73,8881,8882,8884,8887,8889],{"class":75,"line":265},[73,8883,1084],{"class":631},[73,8885,8886],{"class":116}," { items: ",[73,8888,1090],{"class":79},[73,8890,8891],{"class":116},"(items), total, addItem }\n",[73,8893,8894],{"class":75,"line":271},[73,8895,1379],{"class":116},[73,8897,8898],{"class":75,"line":282},[73,8899,130],{"emptyLinePlaceholder":129},[73,8901,8902],{"class":75,"line":293},[73,8903,8904],{"class":106},"// In components:\n",[73,8906,8907,8909,8911,8913,8915],{"class":75,"line":304},[73,8908,1138],{"class":631},[73,8910,5705],{"class":87},[73,8912,956],{"class":631},[73,8914,5710],{"class":79},[73,8916,1154],{"class":116},[73,8918,8919,8922],{"class":75,"line":310},[73,8920,8921],{"class":116},"cart.items ",[73,8923,8924],{"class":106},"// readonly CartItem[]\n",[73,8926,8927,8930],{"class":75,"line":315},[73,8928,8929],{"class":116},"cart.total ",[73,8931,8932],{"class":106},"// number\n",[73,8934,8935,8938],{"class":75,"line":325},[73,8936,8937],{"class":116},"cart.addItem ",[73,8939,8940],{"class":106},"// (item: CartItem) => void\n",[22,8942,8944],{"id":8943},"global-type-augmentation","Global Type Augmentation",[15,8946,8947,8948,8951],{},"For types that should be available globally without importing, add them to a ",[59,8949,8950],{},".d.ts"," file:",[64,8953,8955],{"className":97,"code":8954,"language":99,"meta":69,"style":69},"// global.d.ts\ndeclare global {\n interface Window {\n analytics: AnalyticsInstance\n }\n}\n\n// Augment Vue's ComponentCustomProperties for global properties\ndeclare module 'vue' {\n interface ComponentCustomProperties {\n $config: RuntimeConfig\n }\n}\n\n// Augment Nitro's H3Event for custom context properties\ndeclare module 'h3' {\n interface H3EventContext {\n userId?: string\n session?: Session\n }\n}\n",[59,8956,8957,8962,8970,8980,8990,8994,8998,9002,9007,9018,9027,9037,9041,9045,9049,9054,9065,9074,9083,9093,9097],{"__ignoreMap":69},[73,8958,8959],{"class":75,"line":76},[73,8960,8961],{"class":106},"// global.d.ts\n",[73,8963,8964,8967],{"class":75,"line":110},[73,8965,8966],{"class":631},"declare",[73,8968,8969],{"class":116}," global {\n",[73,8971,8972,8975,8978],{"class":75,"line":126},[73,8973,8974],{"class":631}," interface",[73,8976,8977],{"class":79}," Window",[73,8979,268],{"class":116},[73,8981,8982,8985,8987],{"class":75,"line":133},[73,8983,8984],{"class":624}," analytics",[73,8986,2425],{"class":631},[73,8988,8989],{"class":79}," AnalyticsInstance\n",[73,8991,8992],{"class":75,"line":142},[73,8993,1889],{"class":116},[73,8995,8996],{"class":75,"line":157},[73,8997,1098],{"class":116},[73,8999,9000],{"class":75,"line":165},[73,9001,130],{"emptyLinePlaceholder":129},[73,9003,9004],{"class":75,"line":178},[73,9005,9006],{"class":106},"// Augment Vue's ComponentCustomProperties for global properties\n",[73,9008,9009,9011,9013,9016],{"class":75,"line":191},[73,9010,8966],{"class":631},[73,9012,2762],{"class":631},[73,9014,9015],{"class":83}," 'vue'",[73,9017,268],{"class":116},[73,9019,9020,9022,9025],{"class":75,"line":204},[73,9021,8974],{"class":631},[73,9023,9024],{"class":79}," ComponentCustomProperties",[73,9026,268],{"class":116},[73,9028,9029,9032,9034],{"class":75,"line":217},[73,9030,9031],{"class":624}," $config",[73,9033,2425],{"class":631},[73,9035,9036],{"class":79}," RuntimeConfig\n",[73,9038,9039],{"class":75,"line":230},[73,9040,1889],{"class":116},[73,9042,9043],{"class":75,"line":243},[73,9044,1098],{"class":116},[73,9046,9047],{"class":75,"line":256},[73,9048,130],{"emptyLinePlaceholder":129},[73,9050,9051],{"class":75,"line":265},[73,9052,9053],{"class":106},"// Augment Nitro's H3Event for custom context properties\n",[73,9055,9056,9058,9060,9063],{"class":75,"line":271},[73,9057,8966],{"class":631},[73,9059,2762],{"class":631},[73,9061,9062],{"class":83}," 'h3'",[73,9064,268],{"class":116},[73,9066,9067,9069,9072],{"class":75,"line":282},[73,9068,8974],{"class":631},[73,9070,9071],{"class":79}," H3EventContext",[73,9073,268],{"class":116},[73,9075,9076,9079,9081],{"class":75,"line":293},[73,9077,9078],{"class":624}," userId",[73,9080,8580],{"class":631},[73,9082,8170],{"class":87},[73,9084,9085,9088,9090],{"class":75,"line":304},[73,9086,9087],{"class":624}," session",[73,9089,8580],{"class":631},[73,9091,9092],{"class":79}," Session\n",[73,9094,9095],{"class":75,"line":310},[73,9096,1889],{"class":116},[73,9098,9099],{"class":75,"line":315},[73,9100,1098],{"class":116},[15,9102,9103],{},"The H3 augmentation is particularly useful for middleware that adds properties to the event context — it makes those properties typed throughout your server routes.",[22,9105,9107],{"id":9106},"avoiding-common-typescript-mistakes","Avoiding Common TypeScript Mistakes",[15,9109,9110,9116,9117,9120],{},[32,9111,9112,9113,9115],{},"Do not use ",[59,9114,1742],{}," casts to silence errors."," If you write ",[59,9118,9119],{},"user as User",", you are telling TypeScript to trust you. When that trust is wrong, TypeScript provides no protection. Investigate why the type does not match and fix the root cause.",[15,9122,9123,9128,9129,9131,9132,9135],{},[32,9124,9112,9125,2274],{},[59,9126,9127],{},"any"," ",[59,9130,9127],{}," disables type checking for that value entirely. Use ",[59,9133,9134],{},"unknown"," when you genuinely do not know the type — it forces you to narrow the type before using it.",[15,9137,9138,9146,9147,9149,9150,9152,9153,9155],{},[32,9139,9140,9141,3291,9143,2274],{},"Do not ignore ",[59,9142,1664],{},[59,9144,9145],{},"undefined"," With ",[59,9148,7811],{}," enabled, TypeScript catches null reference errors at compile time. Use optional chaining (",[59,9151,2662],{},") and nullish coalescing (",[59,9154,3106],{},") to handle nullable values explicitly.",[15,9157,9158,9161,9162,9165,9166,9168],{},[32,9159,9160],{},"Handle async errors."," TypeScript does not type the errors in ",[59,9163,9164],{},"catch"," blocks. They are typed as ",[59,9167,9134],{},". Write a type guard:",[64,9170,9172],{"className":97,"code":9171,"language":99,"meta":69,"style":69},"function isError(e: unknown): e is Error {\n return e instanceof Error\n}\n\nTry {\n await riskyOperation()\n} catch (e) {\n if (isError(e)) {\n console.error(e.message) // Now typed as string\n }\n}\n",[59,9173,9174,9205,9217,9221,9225,9230,9239,9249,9261,9274,9278],{"__ignoreMap":69},[73,9175,9176,9178,9181,9183,9185,9187,9190,9192,9194,9197,9200,9203],{"class":75,"line":76},[73,9177,1391],{"class":631},[73,9179,9180],{"class":79}," isError",[73,9182,977],{"class":116},[73,9184,1712],{"class":624},[73,9186,2425],{"class":631},[73,9188,9189],{"class":87}," unknown",[73,9191,8859],{"class":116},[73,9193,2425],{"class":631},[73,9195,9196],{"class":624}," e",[73,9198,9199],{"class":631}," is",[73,9201,9202],{"class":79}," Error",[73,9204,268],{"class":116},[73,9206,9207,9209,9211,9214],{"class":75,"line":110},[73,9208,1084],{"class":631},[73,9210,1739],{"class":116},[73,9212,9213],{"class":631},"instanceof",[73,9215,9216],{"class":79}," Error\n",[73,9218,9219],{"class":75,"line":126},[73,9220,1098],{"class":116},[73,9222,9223],{"class":75,"line":133},[73,9224,130],{"emptyLinePlaceholder":129},[73,9226,9227],{"class":75,"line":142},[73,9228,9229],{"class":116},"Try {\n",[73,9231,9232,9234,9237],{"class":75,"line":157},[73,9233,1401],{"class":631},[73,9235,9236],{"class":79}," riskyOperation",[73,9238,1154],{"class":116},[73,9240,9241,9244,9246],{"class":75,"line":165},[73,9242,9243],{"class":116},"} ",[73,9245,9164],{"class":631},[73,9247,9248],{"class":116}," (e) {\n",[73,9250,9251,9253,9255,9258],{"class":75,"line":178},[73,9252,1814],{"class":631},[73,9254,1817],{"class":116},[73,9256,9257],{"class":79},"isError",[73,9259,9260],{"class":116},"(e)) {\n",[73,9262,9263,9265,9268,9271],{"class":75,"line":191},[73,9264,1354],{"class":116},[73,9266,9267],{"class":79},"error",[73,9269,9270],{"class":116},"(e.message) ",[73,9272,9273],{"class":106},"// Now typed as string\n",[73,9275,9276],{"class":75,"line":204},[73,9277,1889],{"class":116},[73,9279,9280],{"class":75,"line":217},[73,9281,1098],{"class":116},[22,9283,9285],{"id":9284},"running-type-checks-in-ci","Running Type Checks in CI",[15,9287,9288,9289,9292],{},"Add ",[59,9290,9291],{},"nuxi typecheck"," to your CI pipeline:",[64,9294,9296],{"className":7458,"code":9295,"language":7460,"meta":69,"style":69},"# .github/workflows/ci.yml\n- name: Type check\n run: npm run typecheck\n",[59,9297,9298,9303,9315],{"__ignoreMap":69},[73,9299,9300],{"class":75,"line":76},[73,9301,9302],{"class":106},"# .github/workflows/ci.yml\n",[73,9304,9305,9308,9310,9312],{"class":75,"line":110},[73,9306,9307],{"class":116},"- ",[73,9309,7472],{"class":1118},[73,9311,148],{"class":116},[73,9313,9314],{"class":83},"Type check\n",[73,9316,9317,9320,9322],{"class":75,"line":126},[73,9318,9319],{"class":1118}," run",[73,9321,148],{"class":116},[73,9323,9324],{"class":83},"npm run typecheck\n",[15,9326,9327,9328,2425],{},"And in ",[59,9329,4654],{},[64,9331,9333],{"className":4657,"code":9332,"language":4659,"meta":69,"style":69},"{\n \"scripts\": {\n \"typecheck\": \"nuxi typecheck\"\n }\n}\n",[59,9334,9335,9339,9345,9355,9359],{"__ignoreMap":69},[73,9336,9337],{"class":75,"line":76},[73,9338,4666],{"class":116},[73,9340,9341,9343],{"class":75,"line":110},[73,9342,4671],{"class":87},[73,9344,139],{"class":116},[73,9346,9347,9350,9352],{"class":75,"line":126},[73,9348,9349],{"class":87}," \"typecheck\"",[73,9351,148],{"class":116},[73,9353,9354],{"class":83},"\"nuxi typecheck\"\n",[73,9356,9357],{"class":75,"line":133},[73,9358,1889],{"class":116},[73,9360,9361],{"class":75,"line":142},[73,9362,1098],{"class":116},[15,9364,9365,9367],{},[59,9366,9291],{}," runs Volar's TypeScript compilation with Vue template awareness — it catches type errors in templates that the regular TypeScript compiler misses. Running this in CI ensures TypeScript errors block deployment rather than quietly accumulating in the codebase.",[15,9369,9370],{},"TypeScript in Nuxt is no longer a thing you have to fight. The auto-import type generation works, the typed router is excellent, and the integration with Pinia and Zod gives you end-to-end type safety across the full stack. The investment in a properly typed codebase pays back every time you refactor with confidence.",[2326,9372],{},[15,9374,9375,9376,2274],{},"Working on a Nuxt TypeScript project and hitting type issues you cannot resolve, or want to add type safety to an existing JavaScript codebase? I can help. Book a call: ",[2332,9377,2337],{"href":2334,"rel":9378},[2336],[2326,9380],{},[22,9382,2343],{"id":2342},[2304,9384,9385,9389,9395,9399],{},[2307,9386,9387],{},[2332,9388,2369],{"href":2368},[2307,9390,9391],{},[2332,9392,9394],{"href":9393},"/blog/building-rest-apis-typescript","Building REST APIs With TypeScript: Patterns From Production",[2307,9396,9397],{},[2332,9398,2351],{"href":2350},[2307,9400,9401],{},[2332,9402,7],{"href":2395},[2371,9404,9405],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":69,"searchDepth":126,"depth":126,"links":9407},[9408,9409,9410,9411,9412,9413,9414,9415,9416,9417],{"id":7694,"depth":110,"text":7695},{"id":7829,"depth":110,"text":7830},{"id":7995,"depth":110,"text":7996},{"id":8134,"depth":110,"text":8135},{"id":8480,"depth":110,"text":8481},{"id":8696,"depth":110,"text":8697},{"id":8943,"depth":110,"text":8944},{"id":9106,"depth":110,"text":9107},{"id":9284,"depth":110,"text":9285},{"id":2342,"depth":110,"text":2343},"A practical guide to TypeScript in Nuxt 3 and 4 — typed composables, typed routes, typed API responses, auto-import type augmentation, and the tsconfig that works.",[9420,9421],"Nuxt TypeScript","Nuxt type safety",{},"/blog/nuxt-typescript-guide",{"title":7682,"description":9418},"blog/nuxt-typescript-guide",[2399,9427,9428],"TypeScript","Developer Experience","RpO9kF0oKVEhlzTHEhU2_T3922qTg8_ON1hZpCtvFwE",{"id":9431,"title":9432,"author":9433,"body":9434,"category":2385,"date":2386,"description":11362,"extension":2388,"featured":2389,"image":2390,"keywords":11363,"meta":11366,"navigation":129,"path":11367,"readTime":165,"seo":11368,"stem":11369,"tags":11370,"__hash__":11374},"blog/blog/oauth-2-explained.md","OAuth 2.0 Explained for Developers: The Flows That Matter",{"name":9,"bio":10},{"type":12,"value":9435,"toc":11353},[9436,9439,9442,9446,9449,9452,9458,9464,9470,9476,9480,9483,9486,9494,9521,9546,9910,9913,10232,10236,10239,10482,10485,10489,10492,10692,10695,10892,10896,10899,11073,11076,11262,11266,11272,11278,11284,11290,11304,11314,11316,11322,11324,11326,11350],[15,9437,9438],{},"OAuth 2.0 is one of those specifications that takes an hour to understand conceptually and months to implement correctly. The spec has multiple \"flows\" for different scenarios, each with its own security requirements and common mistakes. Most developers have used OAuth as a consumer (Log In With Google) but fewer have implemented it as a provider or understand why specific security measures are required.",[15,9440,9441],{},"This article walks through the flows you actually need to know, why they work the way they do, and the implementation details that separate secure from insecure.",[22,9443,9445],{"id":9444},"what-oauth-20-actually-does","What OAuth 2.0 Actually Does",[15,9447,9448],{},"OAuth 2.0 is an authorization framework, not an authentication protocol. The distinction matters: OAuth grants a third-party application access to a user's resources without sharing the user's credentials. Authentication (who are you?) is a separate concern, handled by OpenID Connect (OIDC), which is built on top of OAuth 2.0.",[15,9450,9451],{},"The four parties in an OAuth interaction:",[15,9453,9454,9457],{},[32,9455,9456],{},"Resource Owner:"," The user who owns the data.",[15,9459,9460,9463],{},[32,9461,9462],{},"Client:"," The application requesting access to the data.",[15,9465,9466,9469],{},[32,9467,9468],{},"Authorization Server:"," Issues tokens after authenticating the user and getting consent.",[15,9471,9472,9475],{},[32,9473,9474],{},"Resource Server:"," Hosts the user's data. Validates tokens before serving resources.",[22,9477,9479],{"id":9478},"the-authorization-code-flow","The Authorization Code Flow",[15,9481,9482],{},"This is the right flow for web applications. Never use the Implicit Flow (it has been deprecated).",[15,9484,9485],{},"The sequence:",[3920,9487,9488,9491],{},[2307,9489,9490],{},"User clicks \"Log in with Google\" in your application",[2307,9492,9493],{},"Your application redirects to Google's authorization endpoint with these parameters:",[2304,9495,9496,9501,9506,9511,9516],{},[2307,9497,9498],{},[59,9499,9500],{},"response_type=code",[2307,9502,9503],{},[59,9504,9505],{},"client_id=your-client-id",[2307,9507,9508],{},[59,9509,9510],{},"redirect_uri=https://yourapp.com/callback",[2307,9512,9513],{},[59,9514,9515],{},"scope=openid email profile",[2307,9517,9518],{},[59,9519,9520],{},"state=random-csrf-value",[3920,9522,9523,9526,9540,9543],{"start":126},[2307,9524,9525],{},"Google authenticates the user and shows a consent screen",[2307,9527,9528,9529,9532,9533,9535,9536,9539],{},"User consents; Google redirects back to your ",[59,9530,9531],{},"redirect_uri"," with a ",[59,9534,59],{}," and the ",[59,9537,9538],{},"state"," value",[2307,9541,9542],{},"Your server exchanges the code for tokens via a server-to-server request (not in the browser)",[2307,9544,9545],{},"Google returns access token, refresh token, and ID token",[64,9547,9549],{"className":97,"code":9548,"language":99,"meta":69,"style":69},"// Step 2: Generate the authorization URL\nfunction getAuthorizationUrl() {\n const state = crypto.randomBytes(16).toString('hex')\n const params = new URLSearchParams({\n response_type: 'code',\n client_id: process.env.GOOGLE_CLIENT_ID!,\n redirect_uri: `${process.env.APP_URL}/auth/callback`,\n scope: 'openid email profile',\n state,\n access_type: 'offline', // Request refresh token\n prompt: 'consent',\n })\n\n return {\n url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,\n state, // Store this in session to verify in callback\n }\n}\n\n// Step 5: Exchange code for tokens\nasync function exchangeCode(code: string) {\n const response = await fetch('https://oauth2.googleapis.com/token', {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n code,\n client_id: process.env.GOOGLE_CLIENT_ID!,\n client_secret: process.env.GOOGLE_CLIENT_SECRET!,\n redirect_uri: `${process.env.APP_URL}/auth/callback`,\n grant_type: 'authorization_code',\n }),\n })\n\n return response.json()\n}\n",[59,9550,9551,9556,9565,9597,9614,9624,9636,9662,9672,9677,9689,9699,9703,9707,9713,9727,9735,9739,9743,9747,9752,9772,9792,9800,9815,9826,9831,9841,9853,9873,9883,9887,9891,9895,9906],{"__ignoreMap":69},[73,9552,9553],{"class":75,"line":76},[73,9554,9555],{"class":106},"// Step 2: Generate the authorization URL\n",[73,9557,9558,9560,9563],{"class":75,"line":110},[73,9559,1391],{"class":631},[73,9561,9562],{"class":79}," getAuthorizationUrl",[73,9564,945],{"class":116},[73,9566,9567,9569,9572,9574,9577,9580,9582,9585,9587,9590,9592,9595],{"class":75,"line":126},[73,9568,950],{"class":631},[73,9570,9571],{"class":87}," state",[73,9573,956],{"class":631},[73,9575,9576],{"class":116}," crypto.",[73,9578,9579],{"class":79},"randomBytes",[73,9581,977],{"class":116},[73,9583,9584],{"class":87},"16",[73,9586,2608],{"class":116},[73,9588,9589],{"class":79},"toString",[73,9591,977],{"class":116},[73,9593,9594],{"class":83},"'hex'",[73,9596,1370],{"class":116},[73,9598,9599,9601,9604,9606,9609,9612],{"class":75,"line":133},[73,9600,950],{"class":631},[73,9602,9603],{"class":87}," params",[73,9605,956],{"class":631},[73,9607,9608],{"class":631}," new",[73,9610,9611],{"class":79}," URLSearchParams",[73,9613,1336],{"class":116},[73,9615,9616,9619,9622],{"class":75,"line":142},[73,9617,9618],{"class":116}," response_type: ",[73,9620,9621],{"class":83},"'code'",[73,9623,154],{"class":116},[73,9625,9626,9629,9632,9634],{"class":75,"line":157},[73,9627,9628],{"class":116}," client_id: process.env.",[73,9630,9631],{"class":87},"GOOGLE_CLIENT_ID",[73,9633,1820],{"class":631},[73,9635,154],{"class":116},[73,9637,9638,9641,9644,9647,9649,9652,9654,9657,9660],{"class":75,"line":165},[73,9639,9640],{"class":116}," redirect_uri: ",[73,9642,9643],{"class":83},"`${",[73,9645,9646],{"class":116},"process",[73,9648,2274],{"class":83},[73,9650,9651],{"class":116},"env",[73,9653,2274],{"class":83},[73,9655,9656],{"class":87},"APP_URL",[73,9658,9659],{"class":83},"}/auth/callback`",[73,9661,154],{"class":116},[73,9663,9664,9667,9670],{"class":75,"line":178},[73,9665,9666],{"class":116}," scope: ",[73,9668,9669],{"class":83},"'openid email profile'",[73,9671,154],{"class":116},[73,9673,9674],{"class":75,"line":191},[73,9675,9676],{"class":116}," state,\n",[73,9678,9679,9682,9684,9686],{"class":75,"line":204},[73,9680,9681],{"class":116}," access_type: ",[73,9683,1008],{"class":83},[73,9685,710],{"class":116},[73,9687,9688],{"class":106},"// Request refresh token\n",[73,9690,9691,9694,9697],{"class":75,"line":217},[73,9692,9693],{"class":116}," prompt: ",[73,9695,9696],{"class":83},"'consent'",[73,9698,154],{"class":116},[73,9700,9701],{"class":75,"line":230},[73,9702,997],{"class":116},[73,9704,9705],{"class":75,"line":243},[73,9706,130],{"emptyLinePlaceholder":129},[73,9708,9709,9711],{"class":75,"line":256},[73,9710,1084],{"class":631},[73,9712,268],{"class":116},[73,9714,9715,9717,9720,9723,9725],{"class":75,"line":265},[73,9716,3140],{"class":116},[73,9718,9719],{"class":83},"`https://accounts.google.com/o/oauth2/v2/auth?${",[73,9721,9722],{"class":116},"params",[73,9724,1367],{"class":83},[73,9726,154],{"class":116},[73,9728,9729,9732],{"class":75,"line":271},[73,9730,9731],{"class":116}," state, ",[73,9733,9734],{"class":106},"// Store this in session to verify in callback\n",[73,9736,9737],{"class":75,"line":282},[73,9738,1889],{"class":116},[73,9740,9741],{"class":75,"line":293},[73,9742,1098],{"class":116},[73,9744,9745],{"class":75,"line":304},[73,9746,130],{"emptyLinePlaceholder":129},[73,9748,9749],{"class":75,"line":310},[73,9750,9751],{"class":106},"// Step 5: Exchange code for tokens\n",[73,9753,9754,9756,9758,9761,9763,9765,9767,9770],{"class":75,"line":315},[73,9755,1996],{"class":631},[73,9757,939],{"class":631},[73,9759,9760],{"class":79}," exchangeCode",[73,9762,977],{"class":116},[73,9764,59],{"class":624},[73,9766,2425],{"class":631},[73,9768,9769],{"class":87}," string",[73,9771,1349],{"class":116},[73,9773,9774,9776,9778,9780,9782,9785,9787,9790],{"class":75,"line":325},[73,9775,950],{"class":631},[73,9777,8439],{"class":87},[73,9779,956],{"class":631},[73,9781,1401],{"class":631},[73,9783,9784],{"class":79}," fetch",[73,9786,977],{"class":116},[73,9788,9789],{"class":83},"'https://oauth2.googleapis.com/token'",[73,9791,2096],{"class":116},[73,9793,9794,9796,9798],{"class":75,"line":335},[73,9795,2101],{"class":116},[73,9797,2104],{"class":83},[73,9799,154],{"class":116},[73,9801,9802,9805,9808,9810,9813],{"class":75,"line":344},[73,9803,9804],{"class":116}," headers: { ",[73,9806,9807],{"class":83},"'Content-Type'",[73,9809,148],{"class":116},[73,9811,9812],{"class":83},"'application/x-www-form-urlencoded'",[73,9814,307],{"class":116},[73,9816,9817,9820,9822,9824],{"class":75,"line":349},[73,9818,9819],{"class":116}," body: ",[73,9821,2950],{"class":631},[73,9823,9611],{"class":79},[73,9825,1336],{"class":116},[73,9827,9828],{"class":75,"line":354},[73,9829,9830],{"class":116}," code,\n",[73,9832,9833,9835,9837,9839],{"class":75,"line":363},[73,9834,9628],{"class":116},[73,9836,9631],{"class":87},[73,9838,1820],{"class":631},[73,9840,154],{"class":116},[73,9842,9843,9846,9849,9851],{"class":75,"line":372},[73,9844,9845],{"class":116}," client_secret: process.env.",[73,9847,9848],{"class":87},"GOOGLE_CLIENT_SECRET",[73,9850,1820],{"class":631},[73,9852,154],{"class":116},[73,9854,9855,9857,9859,9861,9863,9865,9867,9869,9871],{"class":75,"line":381},[73,9856,9640],{"class":116},[73,9858,9643],{"class":83},[73,9860,9646],{"class":116},[73,9862,2274],{"class":83},[73,9864,9651],{"class":116},[73,9866,2274],{"class":83},[73,9868,9656],{"class":87},[73,9870,9659],{"class":83},[73,9872,154],{"class":116},[73,9874,9875,9878,9881],{"class":75,"line":392},[73,9876,9877],{"class":116}," grant_type: ",[73,9879,9880],{"class":83},"'authorization_code'",[73,9882,154],{"class":116},[73,9884,9885],{"class":75,"line":397},[73,9886,3175],{"class":116},[73,9888,9889],{"class":75,"line":403},[73,9890,997],{"class":116},[73,9892,9893],{"class":75,"line":408},[73,9894,130],{"emptyLinePlaceholder":129},[73,9896,9897,9899,9902,9904],{"class":75,"line":416},[73,9898,1084],{"class":631},[73,9900,9901],{"class":116}," response.",[73,9903,4659],{"class":79},[73,9905,1154],{"class":116},[73,9907,9908],{"class":75,"line":429},[73,9909,1098],{"class":116},[15,9911,9912],{},"The callback handler:",[64,9914,9916],{"className":97,"code":9915,"language":99,"meta":69,"style":69},"// OAuth callback handler\napp.get('/auth/callback', async (c) => {\n const { code, state, error } = c.req.query()\n\n // Handle authorization errors\n if (error) {\n return c.redirect(`/login?error=${encodeURIComponent(error)}`)\n }\n\n // Verify state to prevent CSRF\n const sessionState = await getSessionValue(c, 'oauth_state')\n if (state !== sessionState) {\n throw createError({ statusCode: 400, message: 'Invalid state parameter' })\n }\n\n // Exchange code for tokens\n const tokens = await exchangeCode(code)\n\n // Get user info from ID token or userinfo endpoint\n const userInfo = await getUserInfo(tokens.access_token)\n\n // Find or create user in your database\n const user = await upsertUser({\n email: userInfo.email,\n name: userInfo.name,\n avatarUrl: userInfo.picture,\n googleId: userInfo.sub,\n })\n\n // Create your application session\n await createSession(c, user.id)\n return c.redirect('/dashboard')\n})\n",[59,9917,9918,9923,9951,9979,9983,9988,9995,10023,10027,10031,10036,10058,10070,10092,10096,10100,10105,10121,10125,10130,10147,10151,10156,10171,10176,10181,10186,10191,10195,10199,10204,10214,10228],{"__ignoreMap":69},[73,9919,9920],{"class":75,"line":76},[73,9921,9922],{"class":106},"// OAuth callback handler\n",[73,9924,9925,9928,9931,9933,9936,9938,9940,9942,9945,9947,9949],{"class":75,"line":110},[73,9926,9927],{"class":116},"app.",[73,9929,9930],{"class":79},"get",[73,9932,977],{"class":116},[73,9934,9935],{"class":83},"'/auth/callback'",[73,9937,710],{"class":116},[73,9939,1996],{"class":631},[73,9941,1817],{"class":116},[73,9943,9944],{"class":624},"c",[73,9946,1715],{"class":116},[73,9948,632],{"class":631},[73,9950,268],{"class":116},[73,9952,9953,9955,9957,9959,9961,9963,9965,9967,9969,9971,9974,9977],{"class":75,"line":126},[73,9954,950],{"class":631},[73,9956,1141],{"class":116},[73,9958,59],{"class":87},[73,9960,710],{"class":116},[73,9962,9538],{"class":87},[73,9964,710],{"class":116},[73,9966,9267],{"class":87},[73,9968,1147],{"class":116},[73,9970,991],{"class":631},[73,9972,9973],{"class":116}," c.req.",[73,9975,9976],{"class":79},"query",[73,9978,1154],{"class":116},[73,9980,9981],{"class":75,"line":133},[73,9982,130],{"emptyLinePlaceholder":129},[73,9984,9985],{"class":75,"line":142},[73,9986,9987],{"class":106}," // Handle authorization errors\n",[73,9989,9990,9992],{"class":75,"line":157},[73,9991,1814],{"class":631},[73,9993,9994],{"class":116}," (error) {\n",[73,9996,9997,9999,10002,10005,10007,10010,10013,10015,10017,10019,10021],{"class":75,"line":165},[73,9998,1084],{"class":631},[73,10000,10001],{"class":116}," c.",[73,10003,10004],{"class":79},"redirect",[73,10006,977],{"class":116},[73,10008,10009],{"class":83},"`/login?error=${",[73,10011,10012],{"class":79},"encodeURIComponent",[73,10014,977],{"class":83},[73,10016,9267],{"class":116},[73,10018,8859],{"class":83},[73,10020,1367],{"class":83},[73,10022,1370],{"class":116},[73,10024,10025],{"class":75,"line":178},[73,10026,1889],{"class":116},[73,10028,10029],{"class":75,"line":191},[73,10030,130],{"emptyLinePlaceholder":129},[73,10032,10033],{"class":75,"line":204},[73,10034,10035],{"class":106}," // Verify state to prevent CSRF\n",[73,10037,10038,10040,10043,10045,10047,10050,10053,10056],{"class":75,"line":217},[73,10039,950],{"class":631},[73,10041,10042],{"class":87}," sessionState",[73,10044,956],{"class":631},[73,10046,1401],{"class":631},[73,10048,10049],{"class":79}," getSessionValue",[73,10051,10052],{"class":116},"(c, ",[73,10054,10055],{"class":83},"'oauth_state'",[73,10057,1370],{"class":116},[73,10059,10060,10062,10065,10067],{"class":75,"line":230},[73,10061,1814],{"class":631},[73,10063,10064],{"class":116}," (state ",[73,10066,1929],{"class":631},[73,10068,10069],{"class":116}," sessionState) {\n",[73,10071,10072,10075,10078,10081,10084,10087,10090],{"class":75,"line":243},[73,10073,10074],{"class":631}," throw",[73,10076,10077],{"class":79}," createError",[73,10079,10080],{"class":116},"({ statusCode: ",[73,10082,10083],{"class":87},"400",[73,10085,10086],{"class":116},", message: ",[73,10088,10089],{"class":83},"'Invalid state parameter'",[73,10091,997],{"class":116},[73,10093,10094],{"class":75,"line":256},[73,10095,1889],{"class":116},[73,10097,10098],{"class":75,"line":265},[73,10099,130],{"emptyLinePlaceholder":129},[73,10101,10102],{"class":75,"line":271},[73,10103,10104],{"class":106}," // Exchange code for tokens\n",[73,10106,10107,10109,10112,10114,10116,10118],{"class":75,"line":282},[73,10108,950],{"class":631},[73,10110,10111],{"class":87}," tokens",[73,10113,956],{"class":631},[73,10115,1401],{"class":631},[73,10117,9760],{"class":79},[73,10119,10120],{"class":116},"(code)\n",[73,10122,10123],{"class":75,"line":293},[73,10124,130],{"emptyLinePlaceholder":129},[73,10126,10127],{"class":75,"line":304},[73,10128,10129],{"class":106}," // Get user info from ID token or userinfo endpoint\n",[73,10131,10132,10134,10137,10139,10141,10144],{"class":75,"line":310},[73,10133,950],{"class":631},[73,10135,10136],{"class":87}," userInfo",[73,10138,956],{"class":631},[73,10140,1401],{"class":631},[73,10142,10143],{"class":79}," getUserInfo",[73,10145,10146],{"class":116},"(tokens.access_token)\n",[73,10148,10149],{"class":75,"line":315},[73,10150,130],{"emptyLinePlaceholder":129},[73,10152,10153],{"class":75,"line":325},[73,10154,10155],{"class":106}," // Find or create user in your database\n",[73,10157,10158,10160,10162,10164,10166,10169],{"class":75,"line":335},[73,10159,950],{"class":631},[73,10161,7885],{"class":87},[73,10163,956],{"class":631},[73,10165,1401],{"class":631},[73,10167,10168],{"class":79}," upsertUser",[73,10170,1336],{"class":116},[73,10172,10173],{"class":75,"line":344},[73,10174,10175],{"class":116}," email: userInfo.email,\n",[73,10177,10178],{"class":75,"line":349},[73,10179,10180],{"class":116}," name: userInfo.name,\n",[73,10182,10183],{"class":75,"line":354},[73,10184,10185],{"class":116}," avatarUrl: userInfo.picture,\n",[73,10187,10188],{"class":75,"line":363},[73,10189,10190],{"class":116}," googleId: userInfo.sub,\n",[73,10192,10193],{"class":75,"line":372},[73,10194,997],{"class":116},[73,10196,10197],{"class":75,"line":381},[73,10198,130],{"emptyLinePlaceholder":129},[73,10200,10201],{"class":75,"line":392},[73,10202,10203],{"class":106}," // Create your application session\n",[73,10205,10206,10208,10211],{"class":75,"line":397},[73,10207,1401],{"class":631},[73,10209,10210],{"class":79}," createSession",[73,10212,10213],{"class":116},"(c, user.id)\n",[73,10215,10216,10218,10220,10222,10224,10226],{"class":75,"line":403},[73,10217,1084],{"class":631},[73,10219,10001],{"class":116},[73,10221,10004],{"class":79},[73,10223,977],{"class":116},[73,10225,7265],{"class":83},[73,10227,1370],{"class":116},[73,10229,10230],{"class":75,"line":408},[73,10231,1379],{"class":116},[22,10233,10235],{"id":10234},"pkce-required-for-public-clients","PKCE: Required for Public Clients",[15,10237,10238],{},"PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It is required for mobile apps and SPAs (public clients where you cannot keep a client secret truly secret) and recommended for all authorization code flows.",[64,10240,10242],{"className":97,"code":10241,"language":99,"meta":69,"style":69},"// Generate PKCE parameters before the redirect\nfunction generatePKCE() {\n const verifier = crypto.randomBytes(32).toString('base64url')\n const challenge = crypto\n .createHash('sha256')\n .update(verifier)\n .digest('base64url')\n\n return { verifier, challenge }\n}\n\n// Add to the authorization URL\nconst { verifier, challenge } = generatePKCE()\n// Store verifier in session\nconst params = new URLSearchParams({\n // ... Other params\n code_challenge: challenge,\n code_challenge_method: 'S256',\n})\n\n// Include verifier in the token exchange\nconst tokenResponse = await fetch('...token_endpoint', {\n body: new URLSearchParams({\n // ... Other params\n code_verifier: verifier,\n }),\n})\n",[59,10243,10244,10249,10258,10287,10299,10314,10324,10337,10341,10348,10352,10356,10361,10383,10388,10402,10407,10412,10422,10426,10430,10435,10455,10465,10469,10474,10478],{"__ignoreMap":69},[73,10245,10246],{"class":75,"line":76},[73,10247,10248],{"class":106},"// Generate PKCE parameters before the redirect\n",[73,10250,10251,10253,10256],{"class":75,"line":110},[73,10252,1391],{"class":631},[73,10254,10255],{"class":79}," generatePKCE",[73,10257,945],{"class":116},[73,10259,10260,10262,10265,10267,10269,10271,10273,10276,10278,10280,10282,10285],{"class":75,"line":126},[73,10261,950],{"class":631},[73,10263,10264],{"class":87}," verifier",[73,10266,956],{"class":631},[73,10268,9576],{"class":116},[73,10270,9579],{"class":79},[73,10272,977],{"class":116},[73,10274,10275],{"class":87},"32",[73,10277,2608],{"class":116},[73,10279,9589],{"class":79},[73,10281,977],{"class":116},[73,10283,10284],{"class":83},"'base64url'",[73,10286,1370],{"class":116},[73,10288,10289,10291,10294,10296],{"class":75,"line":133},[73,10290,950],{"class":631},[73,10292,10293],{"class":87}," challenge",[73,10295,956],{"class":631},[73,10297,10298],{"class":116}," crypto\n",[73,10300,10301,10304,10307,10309,10312],{"class":75,"line":142},[73,10302,10303],{"class":116}," .",[73,10305,10306],{"class":79},"createHash",[73,10308,977],{"class":116},[73,10310,10311],{"class":83},"'sha256'",[73,10313,1370],{"class":116},[73,10315,10316,10318,10321],{"class":75,"line":157},[73,10317,10303],{"class":116},[73,10319,10320],{"class":79},"update",[73,10322,10323],{"class":116},"(verifier)\n",[73,10325,10326,10328,10331,10333,10335],{"class":75,"line":165},[73,10327,10303],{"class":116},[73,10329,10330],{"class":79},"digest",[73,10332,977],{"class":116},[73,10334,10284],{"class":83},[73,10336,1370],{"class":116},[73,10338,10339],{"class":75,"line":178},[73,10340,130],{"emptyLinePlaceholder":129},[73,10342,10343,10345],{"class":75,"line":191},[73,10344,1084],{"class":631},[73,10346,10347],{"class":116}," { verifier, challenge }\n",[73,10349,10350],{"class":75,"line":204},[73,10351,1098],{"class":116},[73,10353,10354],{"class":75,"line":217},[73,10355,130],{"emptyLinePlaceholder":129},[73,10357,10358],{"class":75,"line":230},[73,10359,10360],{"class":106},"// Add to the authorization URL\n",[73,10362,10363,10365,10367,10370,10372,10375,10377,10379,10381],{"class":75,"line":243},[73,10364,1138],{"class":631},[73,10366,1141],{"class":116},[73,10368,10369],{"class":87},"verifier",[73,10371,710],{"class":116},[73,10373,10374],{"class":87},"challenge",[73,10376,1147],{"class":116},[73,10378,991],{"class":631},[73,10380,10255],{"class":79},[73,10382,1154],{"class":116},[73,10384,10385],{"class":75,"line":256},[73,10386,10387],{"class":106},"// Store verifier in session\n",[73,10389,10390,10392,10394,10396,10398,10400],{"class":75,"line":265},[73,10391,1138],{"class":631},[73,10393,9603],{"class":87},[73,10395,956],{"class":631},[73,10397,9608],{"class":631},[73,10399,9611],{"class":79},[73,10401,1336],{"class":116},[73,10403,10404],{"class":75,"line":271},[73,10405,10406],{"class":106}," // ... Other params\n",[73,10408,10409],{"class":75,"line":282},[73,10410,10411],{"class":116}," code_challenge: challenge,\n",[73,10413,10414,10417,10420],{"class":75,"line":293},[73,10415,10416],{"class":116}," code_challenge_method: ",[73,10418,10419],{"class":83},"'S256'",[73,10421,154],{"class":116},[73,10423,10424],{"class":75,"line":304},[73,10425,1379],{"class":116},[73,10427,10428],{"class":75,"line":310},[73,10429,130],{"emptyLinePlaceholder":129},[73,10431,10432],{"class":75,"line":315},[73,10433,10434],{"class":106},"// Include verifier in the token exchange\n",[73,10436,10437,10439,10442,10444,10446,10448,10450,10453],{"class":75,"line":325},[73,10438,1138],{"class":631},[73,10440,10441],{"class":87}," tokenResponse",[73,10443,956],{"class":631},[73,10445,1401],{"class":631},[73,10447,9784],{"class":79},[73,10449,977],{"class":116},[73,10451,10452],{"class":83},"'...token_endpoint'",[73,10454,2096],{"class":116},[73,10456,10457,10459,10461,10463],{"class":75,"line":335},[73,10458,9819],{"class":116},[73,10460,2950],{"class":631},[73,10462,9611],{"class":79},[73,10464,1336],{"class":116},[73,10466,10467],{"class":75,"line":344},[73,10468,10406],{"class":106},[73,10470,10471],{"class":75,"line":349},[73,10472,10473],{"class":116}," code_verifier: verifier,\n",[73,10475,10476],{"class":75,"line":354},[73,10477,3175],{"class":116},[73,10479,10480],{"class":75,"line":363},[73,10481,1379],{"class":116},[15,10483,10484],{},"If the authorization code is intercepted in transit, the attacker cannot exchange it for tokens without the verifier — which was never transmitted and only exists in the legitimate client's session.",[22,10486,10488],{"id":10487},"client-credentials-flow","Client Credentials Flow",[15,10490,10491],{},"For machine-to-machine (M2M) API communication where there is no user involved. A backend service authenticates directly with the authorization server using its client ID and secret:",[64,10493,10495],{"className":97,"code":10494,"language":99,"meta":69,"style":69},"async function getClientToken() {\n const credentials = Buffer.from(\n `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`\n ).toString('base64')\n\n const response = await fetch('https://auth.server.com/oauth/token', {\n method: 'POST',\n headers: {\n 'Authorization': `Basic ${credentials}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'client_credentials',\n scope: 'read:data write:data',\n }),\n })\n\n return response.json()\n}\n",[59,10496,10497,10508,10524,10556,10570,10574,10593,10601,10606,10623,10634,10638,10648,10657,10666,10670,10674,10678,10688],{"__ignoreMap":69},[73,10498,10499,10501,10503,10506],{"class":75,"line":76},[73,10500,1996],{"class":631},[73,10502,939],{"class":631},[73,10504,10505],{"class":79}," getClientToken",[73,10507,945],{"class":116},[73,10509,10510,10512,10515,10517,10520,10522],{"class":75,"line":110},[73,10511,950],{"class":631},[73,10513,10514],{"class":87}," credentials",[73,10516,956],{"class":631},[73,10518,10519],{"class":116}," Buffer.",[73,10521,2149],{"class":79},[73,10523,2060],{"class":116},[73,10525,10526,10528,10530,10532,10534,10536,10539,10542,10544,10546,10548,10550,10553],{"class":75,"line":126},[73,10527,2652],{"class":83},[73,10529,9646],{"class":116},[73,10531,2274],{"class":83},[73,10533,9651],{"class":116},[73,10535,2274],{"class":83},[73,10537,10538],{"class":87},"CLIENT_ID",[73,10540,10541],{"class":83},"}:${",[73,10543,9646],{"class":116},[73,10545,2274],{"class":83},[73,10547,9651],{"class":116},[73,10549,2274],{"class":83},[73,10551,10552],{"class":87},"CLIENT_SECRET",[73,10554,10555],{"class":83},"}`\n",[73,10557,10558,10561,10563,10565,10568],{"class":75,"line":133},[73,10559,10560],{"class":116}," ).",[73,10562,9589],{"class":79},[73,10564,977],{"class":116},[73,10566,10567],{"class":83},"'base64'",[73,10569,1370],{"class":116},[73,10571,10572],{"class":75,"line":142},[73,10573,130],{"emptyLinePlaceholder":129},[73,10575,10576,10578,10580,10582,10584,10586,10588,10591],{"class":75,"line":157},[73,10577,950],{"class":631},[73,10579,8439],{"class":87},[73,10581,956],{"class":631},[73,10583,1401],{"class":631},[73,10585,9784],{"class":79},[73,10587,977],{"class":116},[73,10589,10590],{"class":83},"'https://auth.server.com/oauth/token'",[73,10592,2096],{"class":116},[73,10594,10595,10597,10599],{"class":75,"line":165},[73,10596,2101],{"class":116},[73,10598,2104],{"class":83},[73,10600,154],{"class":116},[73,10602,10603],{"class":75,"line":178},[73,10604,10605],{"class":116}," headers: {\n",[73,10607,10608,10611,10613,10616,10619,10621],{"class":75,"line":191},[73,10609,10610],{"class":83}," 'Authorization'",[73,10612,148],{"class":116},[73,10614,10615],{"class":83},"`Basic ${",[73,10617,10618],{"class":116},"credentials",[73,10620,1367],{"class":83},[73,10622,154],{"class":116},[73,10624,10625,10628,10630,10632],{"class":75,"line":204},[73,10626,10627],{"class":83}," 'Content-Type'",[73,10629,148],{"class":116},[73,10631,9812],{"class":83},[73,10633,154],{"class":116},[73,10635,10636],{"class":75,"line":217},[73,10637,307],{"class":116},[73,10639,10640,10642,10644,10646],{"class":75,"line":230},[73,10641,9819],{"class":116},[73,10643,2950],{"class":631},[73,10645,9611],{"class":79},[73,10647,1336],{"class":116},[73,10649,10650,10652,10655],{"class":75,"line":243},[73,10651,9877],{"class":116},[73,10653,10654],{"class":83},"'client_credentials'",[73,10656,154],{"class":116},[73,10658,10659,10661,10664],{"class":75,"line":256},[73,10660,9666],{"class":116},[73,10662,10663],{"class":83},"'read:data write:data'",[73,10665,154],{"class":116},[73,10667,10668],{"class":75,"line":265},[73,10669,3175],{"class":116},[73,10671,10672],{"class":75,"line":271},[73,10673,997],{"class":116},[73,10675,10676],{"class":75,"line":282},[73,10677,130],{"emptyLinePlaceholder":129},[73,10679,10680,10682,10684,10686],{"class":75,"line":293},[73,10681,1084],{"class":631},[73,10683,9901],{"class":116},[73,10685,4659],{"class":79},[73,10687,1154],{"class":116},[73,10689,10690],{"class":75,"line":304},[73,10691,1098],{"class":116},[15,10693,10694],{},"Cache the token and refresh it before expiry:",[64,10696,10698],{"className":97,"code":10697,"language":99,"meta":69,"style":69},"let cachedToken: { value: string; expiresAt: number } | null = null\n\nAsync function getServiceToken(): Promise\u003Cstring> {\n if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {\n return cachedToken.value\n }\n\n const { access_token, expires_in } = await getClientToken()\n cachedToken = {\n value: access_token,\n expiresAt: Date.now() + expires_in * 1000,\n }\n\n return access_token\n}\n",[59,10699,10700,10740,10744,10768,10799,10806,10810,10814,10838,10847,10852,10873,10877,10881,10888],{"__ignoreMap":69},[73,10701,10702,10705,10708,10710,10712,10714,10716,10718,10721,10724,10726,10729,10731,10734,10736,10738],{"class":75,"line":76},[73,10703,10704],{"class":631},"let",[73,10706,10707],{"class":116}," cachedToken",[73,10709,2425],{"class":631},[73,10711,1141],{"class":116},[73,10713,2659],{"class":624},[73,10715,2425],{"class":631},[73,10717,9769],{"class":87},[73,10719,10720],{"class":116},"; ",[73,10722,10723],{"class":624},"expiresAt",[73,10725,2425],{"class":631},[73,10727,10728],{"class":87}," number",[73,10730,1147],{"class":116},[73,10732,10733],{"class":631},"|",[73,10735,1658],{"class":87},[73,10737,956],{"class":631},[73,10739,1789],{"class":87},[73,10741,10742],{"class":75,"line":110},[73,10743,130],{"emptyLinePlaceholder":129},[73,10745,10746,10748,10750,10753,10756,10758,10761,10763,10765],{"class":75,"line":126},[73,10747,1388],{"class":116},[73,10749,1391],{"class":631},[73,10751,10752],{"class":79}," getServiceToken",[73,10754,10755],{"class":116},"()",[73,10757,2425],{"class":631},[73,10759,10760],{"class":79}," Promise",[73,10762,1115],{"class":116},[73,10764,7825],{"class":87},[73,10766,10767],{"class":116},"> {\n",[73,10769,10770,10772,10775,10777,10780,10783,10786,10789,10792,10794,10797],{"class":75,"line":133},[73,10771,1814],{"class":631},[73,10773,10774],{"class":116}," (cachedToken ",[73,10776,1924],{"class":631},[73,10778,10779],{"class":116}," cachedToken.expiresAt ",[73,10781,10782],{"class":631},">",[73,10784,10785],{"class":116}," Date.",[73,10787,10788],{"class":79},"now",[73,10790,10791],{"class":116},"() ",[73,10793,8819],{"class":631},[73,10795,10796],{"class":87}," 60000",[73,10798,1349],{"class":116},[73,10800,10801,10803],{"class":75,"line":142},[73,10802,1084],{"class":631},[73,10804,10805],{"class":116}," cachedToken.value\n",[73,10807,10808],{"class":75,"line":157},[73,10809,1889],{"class":116},[73,10811,10812],{"class":75,"line":165},[73,10813,130],{"emptyLinePlaceholder":129},[73,10815,10816,10818,10820,10823,10825,10828,10830,10832,10834,10836],{"class":75,"line":178},[73,10817,950],{"class":631},[73,10819,1141],{"class":116},[73,10821,10822],{"class":87},"access_token",[73,10824,710],{"class":116},[73,10826,10827],{"class":87},"expires_in",[73,10829,1147],{"class":116},[73,10831,991],{"class":631},[73,10833,1401],{"class":631},[73,10835,10505],{"class":79},[73,10837,1154],{"class":116},[73,10839,10840,10843,10845],{"class":75,"line":191},[73,10841,10842],{"class":116}," cachedToken ",[73,10844,991],{"class":631},[73,10846,268],{"class":116},[73,10848,10849],{"class":75,"line":204},[73,10850,10851],{"class":116}," value: access_token,\n",[73,10853,10854,10857,10859,10861,10863,10866,10868,10871],{"class":75,"line":217},[73,10855,10856],{"class":116}," expiresAt: Date.",[73,10858,10788],{"class":79},[73,10860,10791],{"class":116},[73,10862,8819],{"class":631},[73,10864,10865],{"class":116}," expires_in ",[73,10867,4805],{"class":631},[73,10869,10870],{"class":87}," 1000",[73,10872,154],{"class":116},[73,10874,10875],{"class":75,"line":230},[73,10876,1889],{"class":116},[73,10878,10879],{"class":75,"line":243},[73,10880,130],{"emptyLinePlaceholder":129},[73,10882,10883,10885],{"class":75,"line":256},[73,10884,1084],{"class":631},[73,10886,10887],{"class":116}," access_token\n",[73,10889,10890],{"class":75,"line":265},[73,10891,1098],{"class":116},[22,10893,10895],{"id":10894},"token-refresh","Token Refresh",[15,10897,10898],{},"Access tokens expire. Refresh tokens (when provided) allow getting new access tokens without re-authenticating the user:",[64,10900,10902],{"className":97,"code":10901,"language":99,"meta":69,"style":69},"async function refreshAccessToken(refreshToken: string) {\n const response = await fetch('https://accounts.google.com/o/oauth2/token', {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n client_id: process.env.GOOGLE_CLIENT_ID!,\n client_secret: process.env.GOOGLE_CLIENT_SECRET!,\n refresh_token: refreshToken,\n grant_type: 'refresh_token',\n }),\n })\n\n if (!response.ok) {\n // Refresh token expired or revoked — user must re-authenticate\n throw new UnauthorizedError('Refresh token invalid')\n }\n\n return response.json()\n}\n",[59,10903,10904,10924,10943,10951,10963,10973,10983,10993,10998,11007,11011,11015,11019,11030,11035,11051,11055,11059,11069],{"__ignoreMap":69},[73,10905,10906,10908,10910,10913,10915,10918,10920,10922],{"class":75,"line":76},[73,10907,1996],{"class":631},[73,10909,939],{"class":631},[73,10911,10912],{"class":79}," refreshAccessToken",[73,10914,977],{"class":116},[73,10916,10917],{"class":624},"refreshToken",[73,10919,2425],{"class":631},[73,10921,9769],{"class":87},[73,10923,1349],{"class":116},[73,10925,10926,10928,10930,10932,10934,10936,10938,10941],{"class":75,"line":110},[73,10927,950],{"class":631},[73,10929,8439],{"class":87},[73,10931,956],{"class":631},[73,10933,1401],{"class":631},[73,10935,9784],{"class":79},[73,10937,977],{"class":116},[73,10939,10940],{"class":83},"'https://accounts.google.com/o/oauth2/token'",[73,10942,2096],{"class":116},[73,10944,10945,10947,10949],{"class":75,"line":126},[73,10946,2101],{"class":116},[73,10948,2104],{"class":83},[73,10950,154],{"class":116},[73,10952,10953,10955,10957,10959,10961],{"class":75,"line":133},[73,10954,9804],{"class":116},[73,10956,9807],{"class":83},[73,10958,148],{"class":116},[73,10960,9812],{"class":83},[73,10962,307],{"class":116},[73,10964,10965,10967,10969,10971],{"class":75,"line":142},[73,10966,9819],{"class":116},[73,10968,2950],{"class":631},[73,10970,9611],{"class":79},[73,10972,1336],{"class":116},[73,10974,10975,10977,10979,10981],{"class":75,"line":157},[73,10976,9628],{"class":116},[73,10978,9631],{"class":87},[73,10980,1820],{"class":631},[73,10982,154],{"class":116},[73,10984,10985,10987,10989,10991],{"class":75,"line":165},[73,10986,9845],{"class":116},[73,10988,9848],{"class":87},[73,10990,1820],{"class":631},[73,10992,154],{"class":116},[73,10994,10995],{"class":75,"line":178},[73,10996,10997],{"class":116}," refresh_token: refreshToken,\n",[73,10999,11000,11002,11005],{"class":75,"line":191},[73,11001,9877],{"class":116},[73,11003,11004],{"class":83},"'refresh_token'",[73,11006,154],{"class":116},[73,11008,11009],{"class":75,"line":204},[73,11010,3175],{"class":116},[73,11012,11013],{"class":75,"line":217},[73,11014,997],{"class":116},[73,11016,11017],{"class":75,"line":230},[73,11018,130],{"emptyLinePlaceholder":129},[73,11020,11021,11023,11025,11027],{"class":75,"line":243},[73,11022,1814],{"class":631},[73,11024,1817],{"class":116},[73,11026,1820],{"class":631},[73,11028,11029],{"class":116},"response.ok) {\n",[73,11031,11032],{"class":75,"line":256},[73,11033,11034],{"class":106}," // Refresh token expired or revoked — user must re-authenticate\n",[73,11036,11037,11039,11041,11044,11046,11049],{"class":75,"line":265},[73,11038,10074],{"class":631},[73,11040,9608],{"class":631},[73,11042,11043],{"class":79}," UnauthorizedError",[73,11045,977],{"class":116},[73,11047,11048],{"class":83},"'Refresh token invalid'",[73,11050,1370],{"class":116},[73,11052,11053],{"class":75,"line":271},[73,11054,1889],{"class":116},[73,11056,11057],{"class":75,"line":282},[73,11058,130],{"emptyLinePlaceholder":129},[73,11060,11061,11063,11065,11067],{"class":75,"line":293},[73,11062,1084],{"class":631},[73,11064,9901],{"class":116},[73,11066,4659],{"class":79},[73,11068,1154],{"class":116},[73,11070,11071],{"class":75,"line":304},[73,11072,1098],{"class":116},[15,11074,11075],{},"Store tokens encrypted in your database. Never store them in plaintext:",[64,11077,11079],{"className":97,"code":11078,"language":99,"meta":69,"style":69},"import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'\n\nFunction encryptToken(token: string): string {\n const iv = randomBytes(16)\n const cipher = createCipheriv('aes-256-gcm', Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'), iv)\n const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':')\n}\n",[59,11080,11081,11093,11097,11108,11126,11163,11197,11214,11258],{"__ignoreMap":69},[73,11082,11083,11085,11088,11090],{"class":75,"line":76},[73,11084,2143],{"class":631},[73,11086,11087],{"class":116}," { createCipheriv, createDecipheriv, randomBytes } ",[73,11089,2149],{"class":631},[73,11091,11092],{"class":83}," 'crypto'\n",[73,11094,11095],{"class":75,"line":110},[73,11096,130],{"emptyLinePlaceholder":129},[73,11098,11099,11102,11105],{"class":75,"line":126},[73,11100,11101],{"class":116},"Function ",[73,11103,11104],{"class":79},"encryptToken",[73,11106,11107],{"class":116},"(token: string): string {\n",[73,11109,11110,11112,11115,11117,11120,11122,11124],{"class":75,"line":133},[73,11111,950],{"class":631},[73,11113,11114],{"class":87}," iv",[73,11116,956],{"class":631},[73,11118,11119],{"class":79}," randomBytes",[73,11121,977],{"class":116},[73,11123,9584],{"class":87},[73,11125,1370],{"class":116},[73,11127,11128,11130,11133,11135,11138,11140,11143,11146,11148,11151,11154,11156,11158,11160],{"class":75,"line":142},[73,11129,950],{"class":631},[73,11131,11132],{"class":87}," cipher",[73,11134,956],{"class":631},[73,11136,11137],{"class":79}," createCipheriv",[73,11139,977],{"class":116},[73,11141,11142],{"class":83},"'aes-256-gcm'",[73,11144,11145],{"class":116},", Buffer.",[73,11147,2149],{"class":79},[73,11149,11150],{"class":116},"(process.env.",[73,11152,11153],{"class":87},"ENCRYPTION_KEY",[73,11155,1820],{"class":631},[73,11157,710],{"class":116},[73,11159,9594],{"class":83},[73,11161,11162],{"class":116},"), iv)\n",[73,11164,11165,11167,11170,11172,11174,11177,11180,11182,11185,11188,11191,11194],{"class":75,"line":157},[73,11166,950],{"class":631},[73,11168,11169],{"class":87}," encrypted",[73,11171,956],{"class":631},[73,11173,10519],{"class":116},[73,11175,11176],{"class":79},"concat",[73,11178,11179],{"class":116},"([cipher.",[73,11181,10320],{"class":79},[73,11183,11184],{"class":116},"(token, ",[73,11186,11187],{"class":83},"'utf8'",[73,11189,11190],{"class":116},"), cipher.",[73,11192,11193],{"class":79},"final",[73,11195,11196],{"class":116},"()])\n",[73,11198,11199,11201,11204,11206,11209,11212],{"class":75,"line":165},[73,11200,950],{"class":631},[73,11202,11203],{"class":87}," tag",[73,11205,956],{"class":631},[73,11207,11208],{"class":116}," cipher.",[73,11210,11211],{"class":79},"getAuthTag",[73,11213,1154],{"class":116},[73,11215,11216,11218,11221,11223,11225,11227,11230,11232,11234,11236,11239,11241,11243,11245,11248,11251,11253,11256],{"class":75,"line":178},[73,11217,1084],{"class":631},[73,11219,11220],{"class":116}," [iv.",[73,11222,9589],{"class":79},[73,11224,977],{"class":116},[73,11226,9594],{"class":83},[73,11228,11229],{"class":116},"), tag.",[73,11231,9589],{"class":79},[73,11233,977],{"class":116},[73,11235,9594],{"class":83},[73,11237,11238],{"class":116},"), encrypted.",[73,11240,9589],{"class":79},[73,11242,977],{"class":116},[73,11244,9594],{"class":83},[73,11246,11247],{"class":116},")].",[73,11249,11250],{"class":79},"join",[73,11252,977],{"class":116},[73,11254,11255],{"class":83},"':'",[73,11257,1370],{"class":116},[73,11259,11260],{"class":75,"line":191},[73,11261,1098],{"class":116},[22,11263,11265],{"id":11264},"common-security-mistakes","Common Security Mistakes",[15,11267,11268,11271],{},[32,11269,11270],{},"Not validating the state parameter."," The state parameter prevents CSRF attacks — an attacker cannot trick a user into completing an OAuth flow that hands the attacker the resulting tokens. Always generate a cryptographically random state, store it in the session, and verify it in the callback.",[15,11273,11274,11277],{},[32,11275,11276],{},"Using the authorization code flow without PKCE for SPAs."," Public clients should always use PKCE. The client secret is not secret in a browser.",[15,11279,11280,11283],{},[32,11281,11282],{},"Storing tokens in localStorage."," Tokens in localStorage are accessible to any JavaScript on the page, including injected scripts. Use HTTP-only cookies for refresh tokens.",[15,11285,11286,11289],{},[32,11287,11288],{},"Not handling token expiry gracefully."," Expired tokens are a normal case, not an error. Implement silent token refresh so users do not get logged out unnecessarily.",[15,11291,11292,11295,11296,11299,11300,11303],{},[32,11293,11294],{},"Trusting the ID token without verifying the signature."," Always verify the JWT signature using the authorization server's public keys before trusting the claims. Libraries like ",[59,11297,11298],{},"jose"," or ",[59,11301,11302],{},"openid-client"," handle this correctly.",[15,11305,11306,11307,11299,11310,11313],{},"Using a mature library like ",[59,11308,11309],{},"better-auth",[59,11311,11312],{},"@auth/core"," is the right choice for most applications. They handle these security details correctly and are maintained by people who follow OAuth security advisories. Implement OAuth from scratch only when you have specific requirements that libraries cannot meet.",[2326,11315],{},[15,11317,11318,11319,2274],{},"Implementing OAuth for your application or need a security review of an existing auth implementation? Book a call: ",[2332,11320,2337],{"href":2334,"rel":11321},[2336],[2326,11323],{},[22,11325,2343],{"id":2342},[2304,11327,11328,11332,11338,11344],{},[2307,11329,11330],{},[2332,11331,2369],{"href":2368},[2307,11333,11334],{},[2332,11335,11337],{"href":11336},"/blog/jwt-authentication-guide","JWT Authentication: What It Is, How It Works, and Where It Gets Tricky",[2307,11339,11340],{},[2332,11341,11343],{"href":11342},"/blog/enterprise-software-compliance","Compliance in Enterprise Software: What Developers Actually Need to Know",[2307,11345,11346],{},[2332,11347,11349],{"href":11348},"/blog/api-rate-limiting","API Rate Limiting: Protecting Your Services Without Hurting Your Users",[2371,11351,11352],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}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);}",{"title":69,"searchDepth":126,"depth":126,"links":11354},[11355,11356,11357,11358,11359,11360,11361],{"id":9444,"depth":110,"text":9445},{"id":9478,"depth":110,"text":9479},{"id":10234,"depth":110,"text":10235},{"id":10487,"depth":110,"text":10488},{"id":10894,"depth":110,"text":10895},{"id":11264,"depth":110,"text":11265},{"id":2342,"depth":110,"text":2343},"A practical OAuth 2.0 guide for developers — authorization code flow, PKCE, client credentials, token handling, and what actually goes wrong in production implementations.",[11364,11365],"OAuth 2.0","API authentication",{},"/blog/oauth-2-explained",{"title":9432,"description":11362},"blog/oauth-2-explained",[11371,11372,11373],"OAuth","Authentication","Security","y0dpX8CN9JKPkfAh5FVmm2towBVhDfPhLzx6ryTVspA",{"id":11376,"title":11377,"author":11378,"body":11379,"category":11620,"date":2386,"description":11621,"extension":2388,"featured":2389,"image":2390,"keywords":11622,"meta":11625,"navigation":129,"path":11626,"readTime":191,"seo":11627,"stem":11628,"tags":11629,"__hash__":11635},"blog/blog/openai-vs-anthropic-enterprise.md","OpenAI vs Anthropic for Enterprise: Which LLM Should Power Your Application?",{"name":9,"bio":10},{"type":12,"value":11380,"toc":11597},[11381,11385,11388,11391,11393,11397,11402,11405,11408,11412,11415,11418,11422,11425,11428,11432,11435,11437,11441,11445,11448,11451,11455,11458,11462,11465,11469,11472,11474,11478,11482,11485,11488,11492,11495,11499,11502,11504,11508,11511,11517,11523,11529,11532,11534,11538,11544,11550,11556,11559,11567,11569,11571],[22,11382,11384],{"id":11383},"the-question-that-actually-matters","The Question That Actually Matters",[15,11386,11387],{},"Businesses evaluating LLM platforms frequently ask me the wrong question. They ask \"which model is smarter?\" as if intelligence is a single, rankable dimension. The question that actually matters for enterprise software decisions is: \"which platform is the right fit for my specific use case, given my requirements for reliability, safety, cost, and API design?\"",[15,11389,11390],{},"I'll give you my honest assessment. I build primarily on Anthropic's Claude API, so I have a perspective there. I've also integrated OpenAI's API into enterprise systems and have a clear view of the trade-offs.",[2326,11392],{},[22,11394,11396],{"id":11395},"where-claude-anthropic-has-an-edge","Where Claude (Anthropic) Has an Edge",[11398,11399,11401],"h3",{"id":11400},"instruction-following-and-structured-tasks","Instruction Following and Structured Tasks",[15,11403,11404],{},"In my experience building production systems, Claude is more reliable at following complex, multi-part instructions precisely. For enterprise applications where the model needs to adhere to a specific output format, follow a multi-step process, or respect detailed constraints consistently, Claude's instruction-following is a practical advantage.",[15,11406,11407],{},"This matters because enterprise applications often have strict output requirements — specific JSON schemas, particular response structures, format requirements driven by downstream processing. The more reliably the model produces what you specified, the less error handling and retry logic your application needs.",[11398,11409,11411],{"id":11410},"long-context-quality","Long Context Quality",[15,11413,11414],{},"For tasks involving long documents — contract review, codebase analysis, extensive documentation, multi-document synthesis — Claude's performance on long context tasks is strong. The quality of outputs on long context doesn't degrade as significantly as some other models as context length increases.",[15,11416,11417],{},"If your application needs to process long documents reliably, this is a meaningful consideration.",[11398,11419,11421],{"id":11420},"consistent-safety-profile-for-enterprise","Consistent Safety Profile for Enterprise",[15,11423,11424],{},"Claude's Constitutional AI training approach produces a consistent safety profile that is, in my view, more predictable for enterprise applications. This isn't about the model being more restrictive (which would be a drawback for many legitimate use cases) — it's about the safety behavior being more consistent and less likely to vary in surprising ways.",[15,11426,11427],{},"For enterprise applications where erratic behavior (unexpectedly refusing legitimate requests, or unexpectedly permitting content that should be refused) creates real problems, consistency matters.",[11398,11429,11431],{"id":11430},"context-caching-economics","Context Caching Economics",[15,11433,11434],{},"For applications with large, stable system prompts or repeated document context, Anthropic's prompt caching reduces costs significantly. This is a practical economic advantage for enterprise applications that include substantial reference material in every request.",[2326,11436],{},[22,11438,11440],{"id":11439},"where-openai-has-an-edge","Where OpenAI Has an Edge",[11398,11442,11444],{"id":11443},"ecosystem-breadth-and-third-party-integrations","Ecosystem Breadth and Third-Party Integrations",[15,11446,11447],{},"OpenAI arrived earlier to the enterprise market and has a larger third-party integration ecosystem. If you're working with tools, platforms, or services that have pre-built AI integrations, those integrations are more likely to support OpenAI than Anthropic. LangChain integrations, no-code AI tools, enterprise software add-ons — many of these were built with OpenAI first.",[15,11449,11450],{},"If you're building something standard rather than custom, the ecosystem breadth is a practical advantage.",[11398,11452,11454],{"id":11453},"fine-tuning-maturity","Fine-Tuning Maturity",[15,11456,11457],{},"OpenAI's fine-tuning platform has been available longer and is operationally more mature. If your use case requires fine-tuning on domain-specific data — and there are legitimate enterprise use cases where this matters — OpenAI's fine-tuning workflow is more established.",[11398,11459,11461],{"id":11460},"gpt-4os-multimodal-capabilities","GPT-4o's Multimodal Capabilities",[15,11463,11464],{},"For enterprise applications that need to process images, audio, or other modalities alongside text, OpenAI's multimodal capabilities are mature and production-ready. If your use case involves analyzing product images, processing scanned documents with complex formatting, or handling voice input, GPT-4o's multimodal capabilities are a genuine differentiator.",[11398,11466,11468],{"id":11467},"function-calling-ecosystem","Function Calling Ecosystem",[15,11470,11471],{},"OpenAI's function calling (their term for tool use) has a larger body of documented examples, tutorials, and implementation patterns. For teams new to agentic AI development, the documentation depth and community resources around OpenAI's function calling is more extensive.",[2326,11473],{},[22,11475,11477],{"id":11476},"factors-that-are-roughly-equivalent","Factors That Are Roughly Equivalent",[11398,11479,11481],{"id":11480},"raw-capability-on-most-enterprise-tasks","Raw Capability on Most Enterprise Tasks",[15,11483,11484],{},"On the tasks that matter most for typical enterprise applications — document analysis, structured data extraction, code generation, conversational interfaces, classification — the gap between GPT-4 class models and Claude Sonnet/Opus class models is narrow in 2026. Both are capable enough for the vast majority of enterprise use cases.",[15,11486,11487],{},"If someone tells you one is dramatically better than the other across the board, they're selling you something.",[11398,11489,11491],{"id":11490},"pricing-tiers","Pricing Tiers",[15,11493,11494],{},"Both providers have tiered pricing models that reward volume. The absolute cost per token differs, and the cost profile varies by model tier and use pattern (particularly with caching). For specific workloads, one may be materially cheaper. But neither provider has a 5x cost advantage over the other for typical enterprise workloads — evaluate for your specific usage pattern.",[11398,11496,11498],{"id":11497},"api-reliability","API Reliability",[15,11500,11501],{},"Both providers have enterprise service agreements with reliability SLAs. Both have had incidents. Neither is definitively more reliable. Build with fallback strategies regardless of which you choose.",[2326,11503],{},[22,11505,11507],{"id":11506},"my-recommendation-framework","My Recommendation Framework",[15,11509,11510],{},"Here's how I actually make this decision for client projects:",[15,11512,11513,11516],{},[32,11514,11515],{},"Use Anthropic Claude when",": Complex instruction-following is critical. Long document processing is a primary use case. You're building something custom from the API level. Consistent safety behavior matters. You're optimizing for a focused, well-designed API.",[15,11518,11519,11522],{},[32,11520,11521],{},"Use OpenAI when",": Ecosystem integrations matter (you need to plug into tools that support OpenAI). Multimodal capabilities are required. Your team has existing OpenAI expertise and the switching cost exceeds the benefit of changing. Fine-tuning on domain data is a primary requirement.",[15,11524,11525,11528],{},[32,11526,11527],{},"Consider a multi-provider architecture when",": You have diverse use cases with different capability requirements. You want provider redundancy for reliability. You want to use each provider for the tasks where it excels.",[15,11530,11531],{},"The multi-provider architecture is increasingly viable in 2026 because the abstraction layer tooling has improved. It's not trivial — you need to handle different response formats, different error patterns, different tool use APIs — but for production enterprise applications with significant AI usage, the benefits of not being locked into a single provider are real.",[2326,11533],{},[22,11535,11537],{"id":11536},"what-id-caution-against","What I'd Caution Against",[15,11539,11540,11543],{},[32,11541,11542],{},"Optimizing purely on benchmark performance",": Published benchmarks measure specific capabilities under controlled conditions. Your application's performance depends on how well the model handles your specific prompts, your specific data, your specific output requirements. Evaluate on your use cases, not on academic benchmarks.",[15,11545,11546,11549],{},[32,11547,11548],{},"Assuming today's best model will still be the best model in a year",": The model landscape is changing rapidly. Design your application to be model-agnostic at the implementation level even if you're using one provider today. The abstraction that lets you swap models is worth the small amount of added architectural discipline.",[15,11551,11552,11555],{},[32,11553,11554],{},"Making security decisions based on marketing",": Both providers make claims about data privacy, security, and compliance. For enterprise applications handling sensitive data, verify these claims against your specific requirements. Read the API terms of service. Understand what data is retained and how. Don't take marketing materials as compliance verification.",[15,11557,11558],{},"My overall view: both OpenAI and Anthropic are viable enterprise platforms. The platform choice is less important than the quality of your prompt engineering, the architecture of your AI integration, and the rigor of your evaluation and monitoring. A well-built application on either platform will outperform a poorly-built application on the \"better\" platform.",[15,11560,11561,11562,11566],{},"If you're making a platform decision for a specific enterprise AI application and want a perspective informed by production experience on both, ",[2332,11563,11565],{"href":2334,"rel":11564},[2336],"book time with me at Calendly",". I'll help you make the decision based on your actual requirements, not marketing.",[2326,11568],{},[22,11570,2343],{"id":2342},[2304,11572,11573,11579,11585,11591],{},[2307,11574,11575],{},[2332,11576,11578],{"href":11577},"/blog/claude-api-for-developers","The Anthropic Claude API: A Developer's Guide to Building With It",[2307,11580,11581],{},[2332,11582,11584],{"href":11583},"/blog/llm-integration-enterprise-apps","LLM Integration in Enterprise Applications: Patterns and Pitfalls",[2307,11586,11587],{},[2332,11588,11590],{"href":11589},"/blog/building-ai-native-applications","Building AI-Native Applications: Architecture Patterns That Actually Work",[2307,11592,11593],{},[2332,11594,11596],{"href":11595},"/blog/building-chatbots-for-business","Building Chatbots for Business: Beyond the Demo",{"title":69,"searchDepth":126,"depth":126,"links":11598},[11599,11600,11606,11612,11617,11618,11619],{"id":11383,"depth":110,"text":11384},{"id":11395,"depth":110,"text":11396,"children":11601},[11602,11603,11604,11605],{"id":11400,"depth":126,"text":11401},{"id":11410,"depth":126,"text":11411},{"id":11420,"depth":126,"text":11421},{"id":11430,"depth":126,"text":11431},{"id":11439,"depth":110,"text":11440,"children":11607},[11608,11609,11610,11611],{"id":11443,"depth":126,"text":11444},{"id":11453,"depth":126,"text":11454},{"id":11460,"depth":126,"text":11461},{"id":11467,"depth":126,"text":11468},{"id":11476,"depth":110,"text":11477,"children":11613},[11614,11615,11616],{"id":11480,"depth":126,"text":11481},{"id":11490,"depth":126,"text":11491},{"id":11497,"depth":126,"text":11498},{"id":11506,"depth":110,"text":11507},{"id":11536,"depth":110,"text":11537},{"id":2342,"depth":110,"text":2343},"AI","A developer's honest comparison of OpenAI and Anthropic for enterprise AI applications — evaluating capabilities, reliability, safety, pricing, and which use cases favor each provider.",[11623,11624],"OpenAI vs Anthropic enterprise","LLM comparison",{},"/blog/openai-vs-anthropic-enterprise",{"title":11377,"description":11621},"blog/openai-vs-anthropic-enterprise",[11630,11631,11632,11633,11634],"OpenAI","Anthropic","LLM","Enterprise AI","AI Comparison","Do7NOjUFZBinAnUv17HuI7MtLK83eBGXcBySg1PCCH8",{"id":11637,"title":11638,"author":11639,"body":11640,"category":11373,"date":2386,"description":12439,"extension":2388,"featured":2389,"image":2390,"keywords":12440,"meta":12443,"navigation":129,"path":12444,"readTime":178,"seo":12445,"stem":12446,"tags":12447,"__hash__":12451},"blog/blog/owasp-top-10-explained.md","OWASP Top 10 Explained: What Developers Actually Need to Understand",{"name":9,"bio":10},{"type":12,"value":11641,"toc":12426},[11642,11646,11649,11652,11656,11659,11666,11876,11883,11887,11890,11893,11975,11978,11981,11985,11988,12074,12077,12080,12084,12087,12090,12093,12096,12100,12103,12106,12109,12113,12120,12156,12163,12166,12170,12173,12176,12179,12183,12186,12189,12192,12196,12199,12275,12278,12282,12285,12382,12385,12387,12393,12395,12397,12423],[11643,11644,11638],"h1",{"id":11645},"owasp-top-10-explained-what-developers-actually-need-to-understand",[15,11647,11648],{},"The OWASP Top 10 is the security industry's most-cited reference for web application vulnerabilities. Most developers have heard of it. Far fewer have actually read the full documentation and understood what each category means in terms of real code they write every day.",[15,11650,11651],{},"I am going to skip the abstract descriptions and focus on what each category means in practice — the actual code patterns that create vulnerabilities and the concrete changes that prevent them.",[22,11653,11655],{"id":11654},"a01-broken-access-control","A01: Broken Access Control",[15,11657,11658],{},"This is the top vulnerability for a reason. Broken access control means your application does not properly enforce what authenticated users are allowed to do.",[15,11660,11661,11662,11665],{},"The classic example: your API has an endpoint ",[59,11663,11664],{},"GET /api/orders/:orderId"," that returns order details. Your authentication middleware verifies the user is logged in. But does your handler verify the order belongs to the logged-in user?",[64,11667,11669],{"className":97,"code":11668,"language":99,"meta":69,"style":69},"// Vulnerable\napp.get(\"/api/orders/:orderId\", authenticate, async (req, res) => {\n const order = await db.order.findById(req.params.orderId);\n res.json(order); // Returns any order if you know the ID\n});\n\n// Correct\napp.get(\"/api/orders/:orderId\", authenticate, async (req, res) => {\n const order = await db.order.findFirst({\n where: {\n id: req.params.orderId,\n userId: req.user.id, // Enforces ownership\n },\n });\n if (!order) return res.status(404).json({ error: \"Not found\" });\n res.json(order);\n});\n",[59,11670,11671,11676,11708,11728,11741,11746,11750,11755,11783,11800,11805,11810,11818,11822,11827,11863,11872],{"__ignoreMap":69},[73,11672,11673],{"class":75,"line":76},[73,11674,11675],{"class":106},"// Vulnerable\n",[73,11677,11678,11680,11682,11684,11687,11690,11692,11694,11697,11699,11702,11704,11706],{"class":75,"line":110},[73,11679,9927],{"class":116},[73,11681,9930],{"class":79},[73,11683,977],{"class":116},[73,11685,11686],{"class":83},"\"/api/orders/:orderId\"",[73,11688,11689],{"class":116},", authenticate, ",[73,11691,1996],{"class":631},[73,11693,1817],{"class":116},[73,11695,11696],{"class":624},"req",[73,11698,710],{"class":116},[73,11700,11701],{"class":624},"res",[73,11703,1715],{"class":116},[73,11705,632],{"class":631},[73,11707,268],{"class":116},[73,11709,11710,11712,11715,11717,11719,11722,11725],{"class":75,"line":126},[73,11711,950],{"class":631},[73,11713,11714],{"class":87}," order",[73,11716,956],{"class":631},[73,11718,1401],{"class":631},[73,11720,11721],{"class":116}," db.order.",[73,11723,11724],{"class":79},"findById",[73,11726,11727],{"class":116},"(req.params.orderId);\n",[73,11729,11730,11733,11735,11738],{"class":75,"line":133},[73,11731,11732],{"class":116}," res.",[73,11734,4659],{"class":79},[73,11736,11737],{"class":116},"(order); ",[73,11739,11740],{"class":106},"// Returns any order if you know the ID\n",[73,11742,11743],{"class":75,"line":142},[73,11744,11745],{"class":116},"});\n",[73,11747,11748],{"class":75,"line":157},[73,11749,130],{"emptyLinePlaceholder":129},[73,11751,11752],{"class":75,"line":165},[73,11753,11754],{"class":106},"// Correct\n",[73,11756,11757,11759,11761,11763,11765,11767,11769,11771,11773,11775,11777,11779,11781],{"class":75,"line":178},[73,11758,9927],{"class":116},[73,11760,9930],{"class":79},[73,11762,977],{"class":116},[73,11764,11686],{"class":83},[73,11766,11689],{"class":116},[73,11768,1996],{"class":631},[73,11770,1817],{"class":116},[73,11772,11696],{"class":624},[73,11774,710],{"class":116},[73,11776,11701],{"class":624},[73,11778,1715],{"class":116},[73,11780,632],{"class":631},[73,11782,268],{"class":116},[73,11784,11785,11787,11789,11791,11793,11795,11798],{"class":75,"line":191},[73,11786,950],{"class":631},[73,11788,11714],{"class":87},[73,11790,956],{"class":631},[73,11792,1401],{"class":631},[73,11794,11721],{"class":116},[73,11796,11797],{"class":79},"findFirst",[73,11799,1336],{"class":116},[73,11801,11802],{"class":75,"line":204},[73,11803,11804],{"class":116}," where: {\n",[73,11806,11807],{"class":75,"line":217},[73,11808,11809],{"class":116}," id: req.params.orderId,\n",[73,11811,11812,11815],{"class":75,"line":230},[73,11813,11814],{"class":116}," userId: req.user.id, ",[73,11816,11817],{"class":106},"// Enforces ownership\n",[73,11819,11820],{"class":75,"line":243},[73,11821,307],{"class":116},[73,11823,11824],{"class":75,"line":256},[73,11825,11826],{"class":116}," });\n",[73,11828,11829,11831,11833,11835,11838,11841,11843,11846,11848,11851,11853,11855,11858,11861],{"class":75,"line":265},[73,11830,1814],{"class":631},[73,11832,1817],{"class":116},[73,11834,1820],{"class":631},[73,11836,11837],{"class":116},"order) ",[73,11839,11840],{"class":631},"return",[73,11842,11732],{"class":116},[73,11844,11845],{"class":79},"status",[73,11847,977],{"class":116},[73,11849,11850],{"class":87},"404",[73,11852,2608],{"class":116},[73,11854,4659],{"class":79},[73,11856,11857],{"class":116},"({ error: ",[73,11859,11860],{"class":83},"\"Not found\"",[73,11862,11826],{"class":116},[73,11864,11865,11867,11869],{"class":75,"line":271},[73,11866,11732],{"class":116},[73,11868,4659],{"class":79},[73,11870,11871],{"class":116},"(order);\n",[73,11873,11874],{"class":75,"line":282},[73,11875,11745],{"class":116},[15,11877,11878,11879,11882],{},"This is called Insecure Direct Object Reference (IDOR). An attacker changes the ",[59,11880,11881],{},"orderId"," parameter to access other users' orders. Always filter database queries by the authenticated user's context.",[22,11884,11886],{"id":11885},"a02-cryptographic-failures","A02: Cryptographic Failures",[15,11888,11889],{},"Formerly called \"Sensitive Data Exposure\" — this category is about failing to protect data that needs cryptographic protection.",[15,11891,11892],{},"The most common failure: using a weak hashing algorithm for passwords.",[64,11894,11896],{"className":97,"code":11895,"language":99,"meta":69,"style":69},"// Vulnerable — MD5 is fast and reversible via rainbow tables\nconst hash = crypto.createHash(\"md5\").update(password).digest(\"hex\");\n\n// Correct — bcrypt is slow by design, resistant to rainbow tables\nconst hash = await bcrypt.hash(password, 12); // 12 rounds\n",[59,11897,11898,11903,11938,11942,11947],{"__ignoreMap":69},[73,11899,11900],{"class":75,"line":76},[73,11901,11902],{"class":106},"// Vulnerable — MD5 is fast and reversible via rainbow tables\n",[73,11904,11905,11907,11910,11912,11914,11916,11918,11921,11923,11925,11928,11930,11932,11935],{"class":75,"line":110},[73,11906,1138],{"class":631},[73,11908,11909],{"class":87}," hash",[73,11911,956],{"class":631},[73,11913,9576],{"class":116},[73,11915,10306],{"class":79},[73,11917,977],{"class":116},[73,11919,11920],{"class":83},"\"md5\"",[73,11922,2608],{"class":116},[73,11924,10320],{"class":79},[73,11926,11927],{"class":116},"(password).",[73,11929,10330],{"class":79},[73,11931,977],{"class":116},[73,11933,11934],{"class":83},"\"hex\"",[73,11936,11937],{"class":116},");\n",[73,11939,11940],{"class":75,"line":126},[73,11941,130],{"emptyLinePlaceholder":129},[73,11943,11944],{"class":75,"line":133},[73,11945,11946],{"class":106},"// Correct — bcrypt is slow by design, resistant to rainbow tables\n",[73,11948,11949,11951,11953,11955,11957,11960,11963,11966,11969,11972],{"class":75,"line":142},[73,11950,1138],{"class":631},[73,11952,11909],{"class":87},[73,11954,956],{"class":631},[73,11956,1401],{"class":631},[73,11958,11959],{"class":116}," bcrypt.",[73,11961,11962],{"class":79},"hash",[73,11964,11965],{"class":116},"(password, ",[73,11967,11968],{"class":87},"12",[73,11970,11971],{"class":116},"); ",[73,11973,11974],{"class":106},"// 12 rounds\n",[15,11976,11977],{},"Also in this category: transmitting sensitive data over HTTP instead of HTTPS, storing credit card numbers in plaintext, using deprecated SSL/TLS versions, and not encrypting database fields that contain sensitive personal information.",[15,11979,11980],{},"Use HTTPS everywhere. Hash passwords with bcrypt, Argon2id, or scrypt. Encrypt sensitive data fields at rest. Never log passwords, tokens, or card numbers.",[22,11982,11984],{"id":11983},"a03-injection","A03: Injection",[15,11986,11987],{},"SQL injection remains a top vulnerability despite being one of the oldest known attack types. The mechanism: unsanitized user input is incorporated into a SQL query and interpreted as SQL syntax.",[64,11989,11991],{"className":97,"code":11990,"language":99,"meta":69,"style":69},"// Vulnerable\nconst query = `SELECT * FROM users WHERE email = '${req.body.email}'`;\n// If email is: ' OR '1'='1, query returns all users\n\n// Correct — parameterized query\nconst user = await db.query(\n \"SELECT * FROM users WHERE email = $1\",\n [req.body.email]\n);\n",[59,11992,11993,11997,12027,12032,12036,12041,12058,12065,12070],{"__ignoreMap":69},[73,11994,11995],{"class":75,"line":76},[73,11996,11675],{"class":106},[73,11998,11999,12001,12004,12006,12009,12011,12013,12016,12018,12021,12024],{"class":75,"line":110},[73,12000,1138],{"class":631},[73,12002,12003],{"class":87}," query",[73,12005,956],{"class":631},[73,12007,12008],{"class":83}," `SELECT * FROM users WHERE email = '${",[73,12010,11696],{"class":116},[73,12012,2274],{"class":83},[73,12014,12015],{"class":116},"body",[73,12017,2274],{"class":83},[73,12019,12020],{"class":116},"email",[73,12022,12023],{"class":83},"}'`",[73,12025,12026],{"class":116},";\n",[73,12028,12029],{"class":75,"line":126},[73,12030,12031],{"class":106},"// If email is: ' OR '1'='1, query returns all users\n",[73,12033,12034],{"class":75,"line":133},[73,12035,130],{"emptyLinePlaceholder":129},[73,12037,12038],{"class":75,"line":142},[73,12039,12040],{"class":106},"// Correct — parameterized query\n",[73,12042,12043,12045,12047,12049,12051,12054,12056],{"class":75,"line":157},[73,12044,1138],{"class":631},[73,12046,7885],{"class":87},[73,12048,956],{"class":631},[73,12050,1401],{"class":631},[73,12052,12053],{"class":116}," db.",[73,12055,9976],{"class":79},[73,12057,2060],{"class":116},[73,12059,12060,12063],{"class":75,"line":165},[73,12061,12062],{"class":83}," \"SELECT * FROM users WHERE email = $1\"",[73,12064,154],{"class":116},[73,12066,12067],{"class":75,"line":178},[73,12068,12069],{"class":116}," [req.body.email]\n",[73,12071,12072],{"class":75,"line":191},[73,12073,11937],{"class":116},[15,12075,12076],{},"This applies to every injection context: SQL, NoSQL, LDAP, XML, operating system commands. The fix is consistent: never concatenate user input into interpreted strings. Use parameterized queries for databases, safe APIs for OS interaction, and validated formats for everything else.",[15,12078,12079],{},"Modern ORMs (Prisma, TypeORM, Sequelize) use parameterized queries by default. The vulnerability appears when developers bypass the ORM to write raw SQL, often for performance reasons. When you use raw queries, parameterize them.",[22,12081,12083],{"id":12082},"a04-insecure-design","A04: Insecure Design",[15,12085,12086],{},"This category covers architectural security failures — vulnerabilities that result from how the system was designed, not just how it was implemented. No amount of code-level fixes can resolve insecure design.",[15,12088,12089],{},"Example: a password reset flow that sends a 4-digit numeric code via email. An attacker can brute-force 10,000 possible codes, especially if there is no rate limiting on the verification endpoint. The design is insecure regardless of how correctly the implementation is written.",[15,12091,12092],{},"Insecure design requires design-level fixes: use cryptographically random tokens instead of short codes, add rate limiting, add exponential backoff after failed attempts, make codes expire quickly.",[15,12094,12095],{},"Prevention requires threat modeling during design, not security review after implementation. For each user-facing feature, identify misuse cases: what would a malicious user try to do with this? Design to prevent it.",[22,12097,12099],{"id":12098},"a05-security-misconfiguration","A05: Security Misconfiguration",[15,12101,12102],{},"Insecure default configurations, unchanged default credentials, verbose error messages exposing stack traces, unnecessary features enabled, missing security headers — these are all security misconfiguration.",[15,12104,12105],{},"Common examples I find in production: debug mode enabled in production (exposing internal state), default admin credentials unchanged, directory listing enabled in web servers, cloud storage buckets publicly accessible by default, error pages returning stack traces to users.",[15,12107,12108],{},"The fix is environment-specific configuration that locks down production appropriately. Production should have debug mode disabled, verbose logging disabled, default credentials changed, minimal services enabled, and security headers configured. Automate this configuration verification.",[22,12110,12112],{"id":12111},"a06-vulnerable-and-outdated-components","A06: Vulnerable and Outdated Components",[15,12114,12115,12116,12119],{},"Using libraries with known vulnerabilities. Every ",[59,12117,12118],{},"npm install"," pulls in dependencies, and those dependencies have dependencies. Any of them may have known CVEs.",[64,12121,12123],{"className":66,"code":12122,"language":68,"meta":69,"style":69},"# Audit your dependencies\nnpm audit\n\n# Fix automatically fixable issues\nnpm audit fix\n",[59,12124,12125,12130,12137,12141,12146],{"__ignoreMap":69},[73,12126,12127],{"class":75,"line":76},[73,12128,12129],{"class":106},"# Audit your dependencies\n",[73,12131,12132,12134],{"class":75,"line":110},[73,12133,80],{"class":79},[73,12135,12136],{"class":83}," audit\n",[73,12138,12139],{"class":75,"line":126},[73,12140,130],{"emptyLinePlaceholder":129},[73,12142,12143],{"class":75,"line":133},[73,12144,12145],{"class":106},"# Fix automatically fixable issues\n",[73,12147,12148,12150,12153],{"class":75,"line":142},[73,12149,80],{"class":79},[73,12151,12152],{"class":83}," audit",[73,12154,12155],{"class":83}," fix\n",[15,12157,12158,12159,12162],{},"Run ",[59,12160,12161],{},"npm audit"," in CI and fail the build on high-severity vulnerabilities without available fixes disabled. Enable Dependabot or Renovate to automatically create PRs when dependencies have updates available.",[15,12164,12165],{},"This is not just about your direct dependencies. Your entire dependency tree is your attack surface. A critical CVE in a package three levels deep in your dependency graph still affects you.",[22,12167,12169],{"id":12168},"a07-identification-and-authentication-failures","A07: Identification and Authentication Failures",[15,12171,12172],{},"Weak password policies, no multi-factor authentication, session tokens that do not expire, concurrent session handling vulnerabilities, credential stuffing with no detection.",[15,12174,12175],{},"The minimum baseline for production applications: require passwords of at least 12 characters, use bcrypt/Argon2 for hashing, expire sessions after inactivity, implement rate limiting on authentication endpoints, support MFA (TOTP or passkeys), and invalidate sessions on logout.",[15,12177,12178],{},"Credential stuffing — using leaked credential lists from data breaches to try username/password combinations on your application — is increasingly automated and effective. Implement rate limiting, CAPTCHA on repeated failures, and breach password detection (Have I Been Pwned API) to detect and block these attacks.",[22,12180,12182],{"id":12181},"a08-software-and-data-integrity-failures","A08: Software and Data Integrity Failures",[15,12184,12185],{},"This category covers failures to verify the integrity of software or data. The most relevant example for web developers: allowing deserialization of untrusted data without validation.",[15,12187,12188],{},"If your application deserializes user-supplied data into objects (common in session storage, job queues, or API requests), validate the structure before using it. Unvalidated deserialization can allow attackers to manipulate object properties in ways that execute unintended code paths.",[15,12190,12191],{},"Also in this category: CI/CD pipelines with insecure configuration, dependencies pulled from untrusted sources, and automatic updates without integrity verification. Pin your package versions, verify checksums, and use lockfiles.",[22,12193,12195],{"id":12194},"a09-security-logging-and-monitoring-failures","A09: Security Logging and Monitoring Failures",[15,12197,12198],{},"Not logging security-relevant events, or logging them in ways that are not actionable. Failed authentication attempts, access control violations, and high-value data access should be logged with enough context to reconstruct what happened.",[64,12200,12202],{"className":97,"code":12201,"language":99,"meta":69,"style":69},"// Log security events with context\nlogger.warn({\n event: \"authentication.failed\",\n username: req.body.username,\n ip: req.ip,\n userAgent: req.headers[\"user-agent\"],\n timestamp: new Date().toISOString(),\n}, \"Failed login attempt\");\n",[59,12203,12204,12209,12219,12229,12234,12239,12249,12265],{"__ignoreMap":69},[73,12205,12206],{"class":75,"line":76},[73,12207,12208],{"class":106},"// Log security events with context\n",[73,12210,12211,12214,12217],{"class":75,"line":110},[73,12212,12213],{"class":116},"logger.",[73,12215,12216],{"class":79},"warn",[73,12218,1336],{"class":116},[73,12220,12221,12224,12227],{"class":75,"line":126},[73,12222,12223],{"class":116}," event: ",[73,12225,12226],{"class":83},"\"authentication.failed\"",[73,12228,154],{"class":116},[73,12230,12231],{"class":75,"line":133},[73,12232,12233],{"class":116}," username: req.body.username,\n",[73,12235,12236],{"class":75,"line":142},[73,12237,12238],{"class":116}," ip: req.ip,\n",[73,12240,12241,12244,12247],{"class":75,"line":157},[73,12242,12243],{"class":116}," userAgent: req.headers[",[73,12245,12246],{"class":83},"\"user-agent\"",[73,12248,123],{"class":116},[73,12250,12251,12254,12256,12258,12260,12263],{"class":75,"line":165},[73,12252,12253],{"class":116}," timestamp: ",[73,12255,2950],{"class":631},[73,12257,2953],{"class":79},[73,12259,8382],{"class":116},[73,12261,12262],{"class":79},"toISOString",[73,12264,2117],{"class":116},[73,12266,12267,12270,12273],{"class":75,"line":178},[73,12268,12269],{"class":116},"}, ",[73,12271,12272],{"class":83},"\"Failed login attempt\"",[73,12274,11937],{"class":116},[15,12276,12277],{},"These logs need to be shipped to a centralized system and monitored. 100 failed login attempts in 5 minutes from the same IP is a brute-force attempt — your monitoring should alert on it. A user accessing 500 records in 10 minutes is unusual behavior that might indicate data exfiltration.",[22,12279,12281],{"id":12280},"a10-server-side-request-forgery-ssrf","A10: Server-Side Request Forgery (SSRF)",[15,12283,12284],{},"SSRF occurs when your application fetches a URL provided by a user and the request goes somewhere unintended — often to internal services that are not accessible from the internet.",[64,12286,12288],{"className":97,"code":12287,"language":99,"meta":69,"style":69},"// Vulnerable — fetches any URL including internal ones\napp.get(\"/proxy\", async (req, res) => {\n const response = await fetch(req.query.url as string);\n res.send(await response.text());\n});\n\n// An attacker can request: http://169.254.169.254/latest/meta-data/\n// (AWS instance metadata) and get cloud credentials\n",[59,12289,12290,12295,12324,12345,12364,12368,12372,12377],{"__ignoreMap":69},[73,12291,12292],{"class":75,"line":76},[73,12293,12294],{"class":106},"// Vulnerable — fetches any URL including internal ones\n",[73,12296,12297,12299,12301,12303,12306,12308,12310,12312,12314,12316,12318,12320,12322],{"class":75,"line":110},[73,12298,9927],{"class":116},[73,12300,9930],{"class":79},[73,12302,977],{"class":116},[73,12304,12305],{"class":83},"\"/proxy\"",[73,12307,710],{"class":116},[73,12309,1996],{"class":631},[73,12311,1817],{"class":116},[73,12313,11696],{"class":624},[73,12315,710],{"class":116},[73,12317,11701],{"class":624},[73,12319,1715],{"class":116},[73,12321,632],{"class":631},[73,12323,268],{"class":116},[73,12325,12326,12328,12330,12332,12334,12336,12339,12341,12343],{"class":75,"line":126},[73,12327,950],{"class":631},[73,12329,8439],{"class":87},[73,12331,956],{"class":631},[73,12333,1401],{"class":631},[73,12335,9784],{"class":79},[73,12337,12338],{"class":116},"(req.query.url ",[73,12340,1742],{"class":631},[73,12342,9769],{"class":87},[73,12344,11937],{"class":116},[73,12346,12347,12349,12352,12354,12357,12359,12361],{"class":75,"line":133},[73,12348,11732],{"class":116},[73,12350,12351],{"class":79},"send",[73,12353,977],{"class":116},[73,12355,12356],{"class":631},"await",[73,12358,9901],{"class":116},[73,12360,6192],{"class":79},[73,12362,12363],{"class":116},"());\n",[73,12365,12366],{"class":75,"line":142},[73,12367,11745],{"class":116},[73,12369,12370],{"class":75,"line":157},[73,12371,130],{"emptyLinePlaceholder":129},[73,12373,12374],{"class":75,"line":165},[73,12375,12376],{"class":106},"// An attacker can request: http://169.254.169.254/latest/meta-data/\n",[73,12378,12379],{"class":75,"line":178},[73,12380,12381],{"class":106},"// (AWS instance metadata) and get cloud credentials\n",[15,12383,12384],{},"Validate and restrict URLs before fetching them. Block private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16). Use an allowlist of permitted domains rather than a blocklist if possible. Use network-level controls to prevent your application server from reaching internal resources.",[2326,12386],{},[15,12388,12389,12390,2274],{},"Understanding the OWASP Top 10 is the starting point. Implementing mitigations consistently across a codebase requires experience and ongoing attention. If you want help auditing your application against these vulnerabilities, book a session at ",[2332,12391,2334],{"href":2334,"rel":12392},[2336],[2326,12394],{},[22,12396,2343],{"id":2342},[2304,12398,12399,12405,12411,12417],{},[2307,12400,12401],{},[2332,12402,12404],{"href":12403},"/blog/csrf-protection-guide","CSRF Protection: Understanding Cross-Site Request Forgery and Stopping It",[2307,12406,12407],{},[2332,12408,12410],{"href":12409},"/blog/web-security-fundamentals","Web Security Fundamentals Every Developer Should Know",[2307,12412,12413],{},[2332,12414,12416],{"href":12415},"/blog/penetration-testing-small-business","Penetration Testing for Small Businesses: What It Is and When You Need It",[2307,12418,12419],{},[2332,12420,12422],{"href":12421},"/blog/api-security-best-practices","API Security Best Practices: Protecting Your Endpoints in Production",[2371,12424,12425],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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);}",{"title":69,"searchDepth":126,"depth":126,"links":12427},[12428,12429,12430,12431,12432,12433,12434,12435,12436,12437,12438],{"id":11654,"depth":110,"text":11655},{"id":11885,"depth":110,"text":11886},{"id":11983,"depth":110,"text":11984},{"id":12082,"depth":110,"text":12083},{"id":12098,"depth":110,"text":12099},{"id":12111,"depth":110,"text":12112},{"id":12168,"depth":110,"text":12169},{"id":12181,"depth":110,"text":12182},{"id":12194,"depth":110,"text":12195},{"id":12280,"depth":110,"text":12281},{"id":2342,"depth":110,"text":2343},"A developer-focused explanation of the OWASP Top 10 web application security risks — what each means in practice, why it happens, and how to prevent it in your code.",[12441,12442],"OWASP Top 10","web security",{},"/blog/owasp-top-10-explained",{"title":11638,"description":12439},"blog/owasp-top-10-explained",[12448,12449,11373,12450],"OWASP","Web Security","Development","77nHF_ebxSj0UxPUsWxX001czBgnARI_OFqLWgOAl4Q",{"id":12453,"title":12416,"author":12454,"body":12455,"category":11373,"date":2386,"description":12688,"extension":2388,"featured":2389,"image":2390,"keywords":12689,"meta":12692,"navigation":129,"path":12415,"readTime":165,"seo":12693,"stem":12694,"tags":12695,"__hash__":12699},"blog/blog/penetration-testing-small-business.md",{"name":9,"bio":10},{"type":12,"value":12456,"toc":12678},[12457,12460,12463,12466,12470,12473,12476,12479,12483,12489,12495,12501,12507,12510,12514,12517,12523,12529,12535,12541,12547,12550,12554,12557,12577,12580,12583,12587,12590,12596,12602,12608,12614,12620,12624,12627,12630,12633,12636,12640,12643,12646,12648,12654,12656,12658],[11643,12458,12416],{"id":12459},"penetration-testing-for-small-businesses-what-it-is-and-when-you-need-it",[15,12461,12462],{},"Penetration testing is one of those security practices that small businesses hear about, know they probably should care about, and rarely understand well enough to make informed decisions about. The uncertainty usually manifests in one of two ways: either \"we cannot afford that\" (without knowing what it actually costs or whether they need it) or \"we'll get one when we get bigger\" (without knowing what getting bigger has to do with it).",[15,12464,12465],{},"I want to give you a clear-eyed view of what penetration testing is, when it is the right investment, and what to do when you are not ready for one.",[22,12467,12469],{"id":12468},"what-a-penetration-test-actually-is","What a Penetration Test Actually Is",[15,12471,12472],{},"A penetration test is a controlled, authorized attempt to compromise your systems using the same techniques an actual attacker would use. The tester identifies vulnerabilities, attempts to exploit them, chains multiple vulnerabilities together to reach sensitive systems or data, and documents everything they found and did.",[15,12474,12475],{},"The deliverable is a written report. A good report contains an executive summary, a technical findings section (each finding with description, evidence, risk rating, and remediation guidance), and a prioritized remediation roadmap.",[15,12477,12478],{},"What a penetration test is not: a vulnerability scan. Automated vulnerability scans (from tools like Nessus, Qualys, or OpenVAS) enumerate known vulnerabilities in your systems. They are faster, cheaper, and less thorough than a manual penetration test. A pentest involves human judgment — a skilled tester will chain a low-severity finding with another low-severity finding to demonstrate a critical attack path that no automated scanner would identify.",[22,12480,12482],{"id":12481},"types-of-penetration-tests","Types of Penetration Tests",[15,12484,12485,12488],{},[32,12486,12487],{},"Web Application Penetration Test"," — focuses on your web application. The tester attacks your application's authentication, authorization, business logic, and input handling. This is what most software businesses need first.",[15,12490,12491,12494],{},[32,12492,12493],{},"External Network Penetration Test"," — attacks your internet-facing infrastructure from the outside: web servers, API gateways, VPNs, exposed admin interfaces. Simulates what an external attacker would do before they can access your network.",[15,12496,12497,12500],{},[32,12498,12499],{},"Internal Network Penetration Test"," — assumes the attacker is already inside your network (breach scenario, malicious insider, stolen credentials) and tests how far they can move laterally. Relevant once you have significant internal infrastructure.",[15,12502,12503,12506],{},[32,12504,12505],{},"Social Engineering / Phishing"," — tests your employees rather than your systems. Simulated phishing emails, phone calls, physical access attempts. Often sold as a separate engagement.",[15,12508,12509],{},"For a small software business with a web application, start with a web application penetration test. It is where your highest-risk exposure is and where findings are most actionable for your development team.",[22,12511,12513],{"id":12512},"when-do-you-need-one","When Do You Need One?",[15,12515,12516],{},"You need a penetration test when:",[15,12518,12519,12522],{},[32,12520,12521],{},"You store sensitive customer data."," Healthcare data (HIPAA), payment card data (PCI-DSS), or significant volumes of personal data. Regulatory requirements may mandate periodic testing, and the business risk of a breach is high enough to justify the investment.",[15,12524,12525,12528],{},[32,12526,12527],{},"Enterprise customers require it."," B2B sales to mid-market and enterprise customers frequently require a recent penetration test report as part of their vendor security assessment. You will lose deals over this. A pentest report pays for itself if it closes one significant enterprise account.",[15,12530,12531,12534],{},[32,12532,12533],{},"You are approaching compliance certification."," SOC 2 Type II, ISO 27001, and similar frameworks require evidence of security testing. A penetration test feeds directly into this evidence requirement.",[15,12536,12537,12540],{},[32,12538,12539],{},"You have significantly changed your attack surface."," A new product launch, a major architectural change, an acquisition, or opening a new API to third-party integration — each of these materially changes your exposure and warrants a fresh assessment.",[15,12542,12543,12546],{},[32,12544,12545],{},"You have not had one in over a year."," For applications handling sensitive data, annual penetration testing is the standard cadence.",[15,12548,12549],{},"When you probably do not need one yet: you are pre-launch, you are running a simple application with no sensitive data, you have not yet implemented basic security practices (fix those first — a pentest on an insecure application is an expensive report telling you it is insecure in many ways).",[22,12551,12553],{"id":12552},"what-it-costs","What It Costs",[15,12555,12556],{},"For a web application penetration test, realistic pricing:",[2304,12558,12559,12565,12571],{},[2307,12560,12561,12564],{},[32,12562,12563],{},"Small application (under 20 API endpoints, simple auth):"," $3,000-$8,000",[2307,12566,12567,12570],{},[32,12568,12569],{},"Medium application (20-100 endpoints, complex business logic):"," $8,000-$20,000",[2307,12572,12573,12576],{},[32,12574,12575],{},"Large or complex application:"," $20,000+",[15,12578,12579],{},"These are ranges from reputable US-based firms. Offshore providers can be significantly cheaper. The quality varies significantly — I have seen offshore reports that were clearly generated by running automated scans and formatting the output. Ask for example reports before engaging anyone.",[15,12581,12582],{},"The scope drives price significantly. A narrowly scoped engagement (test only the critical payment and authentication flows) costs less than a full-application assessment. For a first engagement, focus the scope on your highest-risk areas.",[22,12584,12586],{"id":12585},"how-to-prepare-for-a-penetration-test","How to Prepare for a Penetration Test",[15,12588,12589],{},"The more prepared you are, the more value you get from the engagement. Before the test begins:",[15,12591,12592,12595],{},[32,12593,12594],{},"Provide documentation."," Share your application architecture documentation, API documentation, user role documentation, and any known security concerns. Testers who understand your system spend less time mapping it and more time testing it.",[15,12597,12598,12601],{},[32,12599,12600],{},"Create test accounts."," Provide accounts at every privilege level your application supports. Admin accounts, standard user accounts, read-only accounts. Some tests also require a \"lower-privileged attacker\" account — a valid user attempting to escalate privileges.",[15,12603,12604,12607],{},[32,12605,12606],{},"Clarify scope."," Explicitly define what is in scope and out of scope. Production or staging environment? Specific subdomains? Any systems that are off-limits? \"Everything\" is rarely the right answer — out-of-scope items (your SaaS providers' infrastructure, for example) need explicit exclusion.",[15,12609,12610,12613],{},[32,12611,12612],{},"Notify relevant parties."," Your hosting provider, your CDN, your security monitoring team. An active penetration test will trigger alerts. Make sure the people watching those alerts know testing is happening so they do not declare an incident.",[15,12615,12616,12619],{},[32,12617,12618],{},"Establish rules of engagement."," Define time windows for testing, emergency contact information if the tester encounters a critical finding mid-engagement, and whether destructive testing (actually deleting data, actually processing payments) is permitted.",[22,12621,12623],{"id":12622},"reading-the-report","Reading the Report",[15,12625,12626],{},"A penetration test report has more value than the vulnerability findings list. The best reports tell a story: here is how I would actually attack your system, step by step, and here is what I would be able to do once I did.",[15,12628,12629],{},"Understanding risk ratings: not all high-severity findings are equally urgent. An unauthenticated SQL injection in your login flow is critical and demands immediate attention. A high-severity finding in an internal admin tool accessible only from the office network requires attention but not emergency response.",[15,12631,12632],{},"Prioritize remediation by: severity + exploitability + impact. A medium-severity finding that is trivially exploitable and gives access to all customer PII may warrant more urgency than a high-severity finding that requires unlikely preconditions to exploit.",[15,12634,12635],{},"After remediation, request a retest. Good firms include a retest in the engagement cost. Retesting verifies your fixes are correct — sometimes remediation introduces new vulnerabilities or only partially addresses the original issue.",[22,12637,12639],{"id":12638},"when-you-are-not-ready-for-a-pentest","When You Are Not Ready for a Pentest",[15,12641,12642],{},"If you have not yet done internal security reviews, you do not yet need an external penetration tester. An honest internal security review — or a security-focused code review by a trusted external developer — will find findings that cost a fraction of a pentest to discover.",[15,12644,12645],{},"The right order: implement secure development practices, run automated security tools (SAST, DAST, dependency scanning) in your CI pipeline, conduct internal security reviews, then engage a professional pentester to find what you missed. Skipping the first steps and going straight to a pentest is like having a professional editor proofread a first draft that has not been spell-checked.",[2326,12647],{},[15,12649,12650,12651,2274],{},"If you want help preparing for a penetration test, evaluating your security posture before engaging a pentest firm, or reviewing pentest report findings and prioritizing remediation, book a session at ",[2332,12652,2334],{"href":2334,"rel":12653},[2336],[2326,12655],{},[22,12657,2343],{"id":2342},[2304,12659,12660,12664,12668,12674],{},[2307,12661,12662],{},[2332,12663,11638],{"href":12444},[2307,12665,12666],{},[2332,12667,12422],{"href":12421},[2307,12669,12670],{},[2332,12671,12673],{"href":12672},"/blog/authentication-security-guide","Authentication Security: What to Get Right Before Your First User Logs In",[2307,12675,12676],{},[2332,12677,12404],{"href":12403},{"title":69,"searchDepth":126,"depth":126,"links":12679},[12680,12681,12682,12683,12684,12685,12686,12687],{"id":12468,"depth":110,"text":12469},{"id":12481,"depth":110,"text":12482},{"id":12512,"depth":110,"text":12513},{"id":12552,"depth":110,"text":12553},{"id":12585,"depth":110,"text":12586},{"id":12622,"depth":110,"text":12623},{"id":12638,"depth":110,"text":12639},{"id":2342,"depth":110,"text":2343},"What penetration testing is, what it costs, how to prepare for one, what the report should contain, and when a small business actually needs a professional pentest.",[12690,12691],"penetration testing","security assessment",{},{"title":12416,"description":12688},"blog/penetration-testing-small-business",[12696,11373,12697,12698],"Penetration Testing","Small Business","Risk Management","TVbUPLxcGB1_zjuhhqH7VdKZ9y6waDo-wKRif1SNX38",{"id":12701,"title":12702,"author":12703,"body":12704,"category":13398,"date":2386,"description":13399,"extension":2388,"featured":2389,"image":2390,"keywords":13400,"meta":13403,"navigation":129,"path":13404,"readTime":165,"seo":13405,"stem":13406,"tags":13407,"__hash__":13411},"blog/blog/performance-monitoring-guide.md","Application Performance Monitoring: Beyond the Health Check Endpoint",{"name":9,"bio":10},{"type":12,"value":12705,"toc":13389},[12706,12709,12712,12715,12719,12722,12728,12734,12740,12746,12752,12756,12759,12762,12765,12954,12957,12990,12993,12996,13000,13003,13006,13028,13031,13038,13077,13083,13090,13099,13102,13106,13109,13112,13118,13124,13130,13133,13140,13273,13276,13280,13283,13286,13312,13315,13318,13322,13325,13345,13348,13350,13356,13358,13360,13386],[11643,12707,12702],{"id":12708},"application-performance-monitoring-beyond-the-health-check-endpoint",[15,12710,12711],{},"A health check endpoint that returns 200 tells you your application process is running. It tells you nothing about whether your application is performing well, why users might be experiencing slow response times, which database queries are responsible for 40% of your API latency, or how your application's performance has changed since the last deployment.",[15,12713,12714],{},"Performance monitoring is about answering those questions with data. Here is how I set it up properly.",[22,12716,12718],{"id":12717},"the-performance-metrics-that-matter","The Performance Metrics That Matter",[15,12720,12721],{},"Performance monitoring starts with defining what \"performance\" means for your specific application. For a web API, the relevant metrics are:",[15,12723,12724,12727],{},[32,12725,12726],{},"Response time by endpoint"," — not just average, but p50, p90, p99, and p99.9. The average latency for your API might be 45ms. The p99 might be 1,200ms. Those are wildly different user experiences, and the average tells you almost nothing about the tail.",[15,12729,12730,12733],{},[32,12731,12732],{},"Database query time"," — which queries are slow, how frequently they run, and whether query performance is consistent or variable (variable indicates table scan behavior that degrades as data grows).",[15,12735,12736,12739],{},[32,12737,12738],{},"External API call latency"," — every call to a third-party service is a latency source you do not control. You need to know which external calls are the slowest and what happens when they time out.",[15,12741,12742,12745],{},[32,12743,12744],{},"Error rate by endpoint"," — percentage of requests that return 4xx or 5xx responses, broken down by endpoint.",[15,12747,12748,12751],{},[32,12749,12750],{},"Throughput"," — requests per second, showing your load patterns and helping you correlate performance changes with traffic changes.",[22,12753,12755],{"id":12754},"distributed-tracing-following-a-request-through-your-system","Distributed Tracing: Following a Request Through Your System",[15,12757,12758],{},"For applications that span multiple services — an API that calls other APIs, reads from a database, puts items on a queue — you need distributed tracing to understand where time is spent within a request.",[15,12760,12761],{},"OpenTelemetry is the standard. It provides vendor-neutral instrumentation libraries that export trace data to any compatible backend (Jaeger, Zipkin, Datadog, Honeycomb, Grafana Tempo).",[15,12763,12764],{},"Instrument your Node.js application:",[64,12766,12768],{"className":97,"code":12767,"language":99,"meta":69,"style":69},"// instrumentation.ts — must be loaded before anything else\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-http\";\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\";\nimport { ExpressInstrumentation } from \"@opentelemetry/instrumentation-express\";\nimport { PgInstrumentation } from \"@opentelemetry/instrumentation-pg\";\n\nConst sdk = new NodeSDK({\n serviceName: \"payment-api\",\n traceExporter: new OTLPTraceExporter({\n url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,\n }),\n instrumentations: [\n new HttpInstrumentation(),\n new ExpressInstrumentation(),\n new PgInstrumentation(),\n ],\n});\n\nSdk.start();\n",[59,12769,12770,12775,12789,12803,12817,12831,12845,12849,12863,12873,12885,12895,12899,12904,12913,12922,12931,12935,12939,12943],{"__ignoreMap":69},[73,12771,12772],{"class":75,"line":76},[73,12773,12774],{"class":106},"// instrumentation.ts — must be loaded before anything else\n",[73,12776,12777,12779,12782,12784,12787],{"class":75,"line":110},[73,12778,2143],{"class":631},[73,12780,12781],{"class":116}," { NodeSDK } ",[73,12783,2149],{"class":631},[73,12785,12786],{"class":83}," \"@opentelemetry/sdk-node\"",[73,12788,12026],{"class":116},[73,12790,12791,12793,12796,12798,12801],{"class":75,"line":126},[73,12792,2143],{"class":631},[73,12794,12795],{"class":116}," { OTLPTraceExporter } ",[73,12797,2149],{"class":631},[73,12799,12800],{"class":83}," \"@opentelemetry/exporter-trace-otlp-http\"",[73,12802,12026],{"class":116},[73,12804,12805,12807,12810,12812,12815],{"class":75,"line":133},[73,12806,2143],{"class":631},[73,12808,12809],{"class":116}," { HttpInstrumentation } ",[73,12811,2149],{"class":631},[73,12813,12814],{"class":83}," \"@opentelemetry/instrumentation-http\"",[73,12816,12026],{"class":116},[73,12818,12819,12821,12824,12826,12829],{"class":75,"line":142},[73,12820,2143],{"class":631},[73,12822,12823],{"class":116}," { ExpressInstrumentation } ",[73,12825,2149],{"class":631},[73,12827,12828],{"class":83}," \"@opentelemetry/instrumentation-express\"",[73,12830,12026],{"class":116},[73,12832,12833,12835,12838,12840,12843],{"class":75,"line":157},[73,12834,2143],{"class":631},[73,12836,12837],{"class":116}," { PgInstrumentation } ",[73,12839,2149],{"class":631},[73,12841,12842],{"class":83}," \"@opentelemetry/instrumentation-pg\"",[73,12844,12026],{"class":116},[73,12846,12847],{"class":75,"line":165},[73,12848,130],{"emptyLinePlaceholder":129},[73,12850,12851,12854,12856,12858,12861],{"class":75,"line":178},[73,12852,12853],{"class":116},"Const sdk ",[73,12855,991],{"class":631},[73,12857,9608],{"class":631},[73,12859,12860],{"class":79}," NodeSDK",[73,12862,1336],{"class":116},[73,12864,12865,12868,12871],{"class":75,"line":191},[73,12866,12867],{"class":116}," serviceName: ",[73,12869,12870],{"class":83},"\"payment-api\"",[73,12872,154],{"class":116},[73,12874,12875,12878,12880,12883],{"class":75,"line":204},[73,12876,12877],{"class":116}," traceExporter: ",[73,12879,2950],{"class":631},[73,12881,12882],{"class":79}," OTLPTraceExporter",[73,12884,1336],{"class":116},[73,12886,12887,12890,12893],{"class":75,"line":217},[73,12888,12889],{"class":116}," url: process.env.",[73,12891,12892],{"class":87},"OTEL_EXPORTER_OTLP_ENDPOINT",[73,12894,154],{"class":116},[73,12896,12897],{"class":75,"line":230},[73,12898,3175],{"class":116},[73,12900,12901],{"class":75,"line":243},[73,12902,12903],{"class":116}," instrumentations: [\n",[73,12905,12906,12908,12911],{"class":75,"line":256},[73,12907,9608],{"class":631},[73,12909,12910],{"class":79}," HttpInstrumentation",[73,12912,2117],{"class":116},[73,12914,12915,12917,12920],{"class":75,"line":265},[73,12916,9608],{"class":631},[73,12918,12919],{"class":79}," ExpressInstrumentation",[73,12921,2117],{"class":116},[73,12923,12924,12926,12929],{"class":75,"line":271},[73,12925,9608],{"class":631},[73,12927,12928],{"class":79}," PgInstrumentation",[73,12930,2117],{"class":116},[73,12932,12933],{"class":75,"line":282},[73,12934,400],{"class":116},[73,12936,12937],{"class":75,"line":293},[73,12938,11745],{"class":116},[73,12940,12941],{"class":75,"line":304},[73,12942,130],{"emptyLinePlaceholder":129},[73,12944,12945,12948,12951],{"class":75,"line":310},[73,12946,12947],{"class":116},"Sdk.",[73,12949,12950],{"class":79},"start",[73,12952,12953],{"class":116},"();\n",[15,12955,12956],{},"Load this before your application code:",[64,12958,12960],{"className":4657,"code":12959,"language":4659,"meta":69,"style":69},"{\n \"scripts\": {\n \"start\": \"node --require ./instrumentation.js src/index.js\"\n }\n}\n",[59,12961,12962,12966,12972,12982,12986],{"__ignoreMap":69},[73,12963,12964],{"class":75,"line":76},[73,12965,4666],{"class":116},[73,12967,12968,12970],{"class":75,"line":110},[73,12969,4671],{"class":87},[73,12971,139],{"class":116},[73,12973,12974,12977,12979],{"class":75,"line":126},[73,12975,12976],{"class":87}," \"start\"",[73,12978,148],{"class":116},[73,12980,12981],{"class":83},"\"node --require ./instrumentation.js src/index.js\"\n",[73,12983,12984],{"class":75,"line":133},[73,12985,1889],{"class":116},[73,12987,12988],{"class":75,"line":142},[73,12989,1098],{"class":116},[15,12991,12992],{},"With this in place, every HTTP request creates a trace with spans for each operation: the incoming HTTP request, each database query, each outbound HTTP call. You can see the waterfall of operations for any request — total time, time per operation, where the bottleneck is.",[15,12994,12995],{},"A trace that shows a 1,200ms API response might reveal: 5ms routing overhead, 800ms for a single database query, 350ms for an external API call, 45ms for everything else. The database query is the bottleneck. That is actionable.",[22,12997,12999],{"id":12998},"database-query-performance","Database Query Performance",[15,13001,13002],{},"Database performance degrades silently. A query that takes 10ms with 10,000 rows takes 1,200ms with 1 million rows if it is doing a sequential scan. Unless you are watching query times over time, you will not notice until users start complaining.",[15,13004,13005],{},"Enable Postgres slow query logging:",[64,13007,13011],{"className":13008,"code":13009,"language":13010,"meta":69,"style":69},"language-sql shiki shiki-themes github-dark","-- In postgresql.conf or via ALTER SYSTEM\nlog_min_duration_statement = 100 -- Log queries taking over 100ms\nlog_statement = 'none'\n","sql",[59,13012,13013,13018,13023],{"__ignoreMap":69},[73,13014,13015],{"class":75,"line":76},[73,13016,13017],{},"-- In postgresql.conf or via ALTER SYSTEM\n",[73,13019,13020],{"class":75,"line":110},[73,13021,13022],{},"log_min_duration_statement = 100 -- Log queries taking over 100ms\n",[73,13024,13025],{"class":75,"line":126},[73,13026,13027],{},"log_statement = 'none'\n",[15,13029,13030],{},"This logs every query that takes over 100ms. Review these logs weekly. Any query appearing regularly in slow query logs needs an index or query optimization.",[15,13032,13033,13034,13037],{},"For more sophisticated analysis, use ",[59,13035,13036],{},"pg_stat_statements",". It tracks execution statistics for all queries:",[64,13039,13041],{"className":13008,"code":13040,"language":13010,"meta":69,"style":69},"CREATE EXTENSION pg_stat_statements;\n\n-- Find the slowest queries by total time\nSELECT query, calls, total_exec_time, mean_exec_time, rows\nFROM pg_stat_statements\nORDER BY total_exec_time DESC\nLIMIT 20;\n",[59,13042,13043,13048,13052,13057,13062,13067,13072],{"__ignoreMap":69},[73,13044,13045],{"class":75,"line":76},[73,13046,13047],{},"CREATE EXTENSION pg_stat_statements;\n",[73,13049,13050],{"class":75,"line":110},[73,13051,130],{"emptyLinePlaceholder":129},[73,13053,13054],{"class":75,"line":126},[73,13055,13056],{},"-- Find the slowest queries by total time\n",[73,13058,13059],{"class":75,"line":133},[73,13060,13061],{},"SELECT query, calls, total_exec_time, mean_exec_time, rows\n",[73,13063,13064],{"class":75,"line":142},[73,13065,13066],{},"FROM pg_stat_statements\n",[73,13068,13069],{"class":75,"line":157},[73,13070,13071],{},"ORDER BY total_exec_time DESC\n",[73,13073,13074],{"class":75,"line":165},[73,13075,13076],{},"LIMIT 20;\n",[15,13078,57,13079,13082],{},[59,13080,13081],{},"total_exec_time"," column shows you which queries are consuming the most cumulative time — even if individual calls are fast, a query called 10,000 times at 50ms each totals 500 seconds of database time. These high-call-count queries are worth optimizing even if the individual execution time seems acceptable.",[15,13084,13085,13086,13089],{},"Use ",[59,13087,13088],{},"EXPLAIN ANALYZE"," on slow queries to see the query plan:",[64,13091,13093],{"className":13008,"code":13092,"language":13010,"meta":69,"style":69},"EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10;\n",[59,13094,13095],{"__ignoreMap":69},[73,13096,13097],{"class":75,"line":76},[73,13098,13092],{},[15,13100,13101],{},"If the plan shows \"Seq Scan\" on a large table, you need an index. If it shows \"Index Scan\" but is still slow, the index might not be selective enough, or the query might be returning too many rows.",[22,13103,13105],{"id":13104},"frontend-performance-with-core-web-vitals","Frontend Performance with Core Web Vitals",[15,13107,13108],{},"Backend latency is only part of user-perceived performance. The frontend rendering pipeline — JavaScript execution, CSS parsing, image loading, layout calculation — contributes significantly to what users actually experience.",[15,13110,13111],{},"Core Web Vitals are Google's standardized metrics for user experience:",[15,13113,13114,13117],{},[32,13115,13116],{},"Largest Contentful Paint (LCP)"," — when does the main content load? Target under 2.5 seconds.",[15,13119,13120,13123],{},[32,13121,13122],{},"Interaction to Next Paint (INP)"," — how quickly does the page respond to user interaction? Target under 200ms.",[15,13125,13126,13129],{},[32,13127,13128],{},"Cumulative Layout Shift (CLS)"," — how much does the layout jump around as content loads? Target under 0.1.",[15,13131,13132],{},"Measure these from real user sessions, not from synthetic Lighthouse tests. Lighthouse on a fast developer laptop with a fast internet connection is not representative of your users' experience. Use the Chrome User Experience Report, or install a Real User Monitoring (RUM) tool.",[15,13134,13135,13136,13139],{},"For Nuxt and Next.js applications, Vercel Analytics and Netlify Analytics provide Core Web Vitals data from real users. For custom deployment targets, integrate the ",[59,13137,13138],{},"web-vitals"," library:",[64,13141,13143],{"className":97,"code":13142,"language":99,"meta":69,"style":69},"import { getCLS, getFID, getFCP, getLCP, getTTFB } from \"web-vitals\";\n\nFunction sendToAnalytics(metric: { name: string; value: number; delta: number }) {\n // Send to your analytics endpoint\n fetch(\"/api/metrics\", {\n method: \"POST\",\n body: JSON.stringify(metric),\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\nGetCLS(sendToAnalytics);\ngetFID(sendToAnalytics);\ngetFCP(sendToAnalytics);\ngetLCP(sendToAnalytics);\ngetTTFB(sendToAnalytics);\n",[59,13144,13145,13159,13163,13173,13178,13189,13198,13211,13225,13229,13233,13237,13245,13252,13259,13266],{"__ignoreMap":69},[73,13146,13147,13149,13152,13154,13157],{"class":75,"line":76},[73,13148,2143],{"class":631},[73,13150,13151],{"class":116}," { getCLS, getFID, getFCP, getLCP, getTTFB } ",[73,13153,2149],{"class":631},[73,13155,13156],{"class":83}," \"web-vitals\"",[73,13158,12026],{"class":116},[73,13160,13161],{"class":75,"line":110},[73,13162,130],{"emptyLinePlaceholder":129},[73,13164,13165,13167,13170],{"class":75,"line":126},[73,13166,11101],{"class":116},[73,13168,13169],{"class":79},"sendToAnalytics",[73,13171,13172],{"class":116},"(metric: { name: string; value: number; delta: number }) {\n",[73,13174,13175],{"class":75,"line":133},[73,13176,13177],{"class":106}," // Send to your analytics endpoint\n",[73,13179,13180,13182,13184,13187],{"class":75,"line":142},[73,13181,9784],{"class":79},[73,13183,977],{"class":116},[73,13185,13186],{"class":83},"\"/api/metrics\"",[73,13188,2096],{"class":116},[73,13190,13191,13193,13196],{"class":75,"line":157},[73,13192,2101],{"class":116},[73,13194,13195],{"class":83},"\"POST\"",[73,13197,154],{"class":116},[73,13199,13200,13202,13204,13206,13208],{"class":75,"line":165},[73,13201,9819],{"class":116},[73,13203,2271],{"class":87},[73,13205,2274],{"class":116},[73,13207,2277],{"class":79},[73,13209,13210],{"class":116},"(metric),\n",[73,13212,13213,13215,13218,13220,13223],{"class":75,"line":178},[73,13214,9804],{"class":116},[73,13216,13217],{"class":83},"\"Content-Type\"",[73,13219,148],{"class":116},[73,13221,13222],{"class":83},"\"application/json\"",[73,13224,307],{"class":116},[73,13226,13227],{"class":75,"line":191},[73,13228,11826],{"class":116},[73,13230,13231],{"class":75,"line":204},[73,13232,1098],{"class":116},[73,13234,13235],{"class":75,"line":217},[73,13236,130],{"emptyLinePlaceholder":129},[73,13238,13239,13242],{"class":75,"line":230},[73,13240,13241],{"class":79},"GetCLS",[73,13243,13244],{"class":116},"(sendToAnalytics);\n",[73,13246,13247,13250],{"class":75,"line":243},[73,13248,13249],{"class":79},"getFID",[73,13251,13244],{"class":116},[73,13253,13254,13257],{"class":75,"line":256},[73,13255,13256],{"class":79},"getFCP",[73,13258,13244],{"class":116},[73,13260,13261,13264],{"class":75,"line":265},[73,13262,13263],{"class":79},"getLCP",[73,13265,13244],{"class":116},[73,13267,13268,13271],{"class":75,"line":271},[73,13269,13270],{"class":79},"getTTFB",[73,13272,13244],{"class":116},[15,13274,13275],{},"Collect these metrics in your analytics database and build a dashboard showing p75 values for each metric over time. The target for Core Web Vitals is the 75th percentile — you want 75% of your users to have a good experience.",[22,13277,13279],{"id":13278},"performance-regression-detection","Performance Regression Detection",[15,13281,13282],{},"The most valuable performance monitoring is detecting regressions immediately after deployment. Set up a performance comparison between your last production deployment and the current one.",[15,13284,13285],{},"After every deployment, run a synthetic load test against your staging environment and compare key endpoint latencies to the baseline:",[64,13287,13289],{"className":66,"code":13288,"language":68,"meta":69,"style":69},"# Using k6 for a basic load test\nk6 run --env BASE_URL=https://staging.myapp.com scripts/load-test.js\n",[59,13290,13291,13296],{"__ignoreMap":69},[73,13292,13293],{"class":75,"line":76},[73,13294,13295],{"class":106},"# Using k6 for a basic load test\n",[73,13297,13298,13301,13303,13306,13309],{"class":75,"line":110},[73,13299,13300],{"class":79},"k6",[73,13302,9319],{"class":83},[73,13304,13305],{"class":87}," --env",[73,13307,13308],{"class":83}," BASE_URL=https://staging.myapp.com",[73,13310,13311],{"class":83}," scripts/load-test.js\n",[15,13313,13314],{},"If p99 latency for your critical endpoints increased by more than 20% compared to the previous deployment, that is a regression. Catch it in staging before it reaches production.",[15,13316,13317],{},"Set up a deployment annotation in your monitoring dashboards. Every time a deployment happens, mark it on your performance graphs. This makes correlating performance changes with deployments trivial — you can see exactly when a latency spike started and match it to the deployment that caused it.",[22,13319,13321],{"id":13320},"building-the-performance-dashboard","Building the Performance Dashboard",[15,13323,13324],{},"A single dashboard with six charts covers the performance visibility I want for most applications:",[3920,13326,13327,13330,13333,13336,13339,13342],{},[2307,13328,13329],{},"API p50/p90/p99 latency (last 24 hours, rolling)",[2307,13331,13332],{},"Error rate by endpoint (last 24 hours)",[2307,13334,13335],{},"Requests per second (last 24 hours)",[2307,13337,13338],{},"Top 10 slowest average database queries (last hour)",[2307,13340,13341],{},"LCP p75 from real users (last 7 days)",[2307,13343,13344],{},"External API call latency by provider (last 24 hours)",[15,13346,13347],{},"These six panels answer \"how is my application performing\" across the full stack. Any significant degradation shows up in at least one of these panels.",[2326,13349],{},[15,13351,13352,13353,2274],{},"If you want help setting up performance monitoring for your application or have a specific performance problem you are trying to diagnose, book a session at ",[2332,13354,2334],{"href":2334,"rel":13355},[2336],[2326,13357],{},[22,13359,2343],{"id":2342},[2304,13361,13362,13368,13374,13380],{},[2307,13363,13364],{},[2332,13365,13367],{"href":13366},"/blog/production-monitoring-guide","Production Monitoring: The Metrics That Actually Tell You Something Is Wrong",[2307,13369,13370],{},[2332,13371,13373],{"href":13372},"/blog/logging-production-apps","Structured Logging for Production: The Setup You'll Thank Yourself For",[2307,13375,13376],{},[2332,13377,13379],{"href":13378},"/blog/kubernetes-basics-developers","Kubernetes for Application Developers: What You Actually Need to Know",[2307,13381,13382],{},[2332,13383,13385],{"href":13384},"/blog/cdn-configuration-guide","CDN Configuration: Making Your Static Assets Load Instantly Everywhere",[2371,13387,13388],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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);}",{"title":69,"searchDepth":126,"depth":126,"links":13390},[13391,13392,13393,13394,13395,13396,13397],{"id":12717,"depth":110,"text":12718},{"id":12754,"depth":110,"text":12755},{"id":12998,"depth":110,"text":12999},{"id":13104,"depth":110,"text":13105},{"id":13278,"depth":110,"text":13279},{"id":13320,"depth":110,"text":13321},{"id":2342,"depth":110,"text":2343},"DevOps","Real application performance monitoring — distributed tracing, Core Web Vitals, database query analysis, and building performance dashboards that surface actionable insights.",[13401,13402],"performance monitoring","application performance",{},"/blog/performance-monitoring-guide",{"title":12702,"description":13399},"blog/performance-monitoring-guide",[13408,13409,13410,13398],"Performance Monitoring","APM","Observability","mI7myH7OMrSJV44O81u3B7qxTvs2A8OaSosOOEKV9p4",{"id":13413,"title":13414,"author":13415,"body":13416,"category":2385,"date":2386,"description":15023,"extension":2388,"featured":2389,"image":2390,"keywords":15024,"meta":15027,"navigation":129,"path":15028,"readTime":165,"seo":15029,"stem":15030,"tags":15031,"__hash__":15035},"blog/blog/pinia-state-management-guide.md","Pinia State Management: The Vue Store That Replaced Vuex",{"name":9,"bio":10},{"type":12,"value":13417,"toc":15011},[13418,13421,13424,13428,13431,13434,13710,13713,13717,13720,13825,13833,13836,13840,13848,13923,13926,13929,14234,14238,14241,14430,14433,14437,14444,14504,14507,14649,14652,14656,14659,14665,14672,14675,14677,14683,14944,14950,14954,14957,14961,14964,14967,14970,14972,14978,14980,14982,15008],[15,13419,13420],{},"Vuex served Vue well, but it always had a friction problem. The four-concept API (state, getters, mutations, actions) felt heavyweight for most real-world use cases. Mutations existed to enable devtools tracking, but they were verbose and added a layer of indirection that confused developers coming from other frameworks. When Pinia landed as the official state management recommendation for Vue 3, it felt like the community finally exhaled.",[15,13422,13423],{},"I have been using Pinia in production since it was still in early releases, and I have opinions about how to use it well. Here is what I have learned.",[22,13425,13427],{"id":13426},"why-pinia-over-vuex","Why Pinia Over Vuex",[15,13429,13430],{},"The pitch is simple. Pinia gives you Vue 3's Composition API ergonomics in a store. No mutations — actions can mutate state directly. Full TypeScript inference without plugins or workarounds. Devtools integration that actually works. Store composition that does not feel like a workaround.",[15,13432,13433],{},"The API surface is smaller and more intuitive. Here is a complete Pinia store:",[64,13435,13437],{"className":97,"code":13436,"language":99,"meta":69,"style":69},"// stores/user.ts\nimport { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\n\nExport const useUserStore = defineStore('user', () => {\n const user = ref\u003CUser | null>(null)\n const loading = ref(false)\n\n const isAuthenticated = computed(() => user.value !== null)\n const displayName = computed(() => user.value?.name ?? 'Guest')\n\n async function fetchUser() {\n loading.value = true\n try {\n const response = await $fetch('/api/user/me')\n user.value = response\n } finally {\n loading.value = false\n }\n }\n\n function logout() {\n user.value = null\n }\n\n return { user, loading, isAuthenticated, displayName, fetchUser, logout }\n})\n",[59,13438,13439,13444,13455,13467,13471,13494,13518,13534,13538,13560,13585,13589,13600,13609,13616,13635,13644,13653,13662,13666,13670,13674,13683,13691,13695,13699,13706],{"__ignoreMap":69},[73,13440,13441],{"class":75,"line":76},[73,13442,13443],{"class":106},"// stores/user.ts\n",[73,13445,13446,13448,13451,13453],{"class":75,"line":110},[73,13447,2143],{"class":631},[73,13449,13450],{"class":116}," { defineStore } ",[73,13452,2149],{"class":631},[73,13454,5622],{"class":83},[73,13456,13457,13459,13462,13464],{"class":75,"line":126},[73,13458,2143],{"class":631},[73,13460,13461],{"class":116}," { ref, computed } ",[73,13463,2149],{"class":631},[73,13465,13466],{"class":83}," 'vue'\n",[73,13468,13469],{"class":75,"line":133},[73,13470,130],{"emptyLinePlaceholder":129},[73,13472,13473,13475,13477,13480,13482,13484,13486,13488,13490,13492],{"class":75,"line":142},[73,13474,2208],{"class":116},[73,13476,1138],{"class":631},[73,13478,13479],{"class":87}," useUserStore",[73,13481,956],{"class":631},[73,13483,8741],{"class":79},[73,13485,977],{"class":116},[73,13487,7904],{"class":83},[73,13489,983],{"class":116},[73,13491,632],{"class":631},[73,13493,268],{"class":116},[73,13495,13496,13498,13500,13502,13504,13506,13508,13510,13512,13514,13516],{"class":75,"line":157},[73,13497,950],{"class":631},[73,13499,7885],{"class":87},[73,13501,956],{"class":631},[73,13503,959],{"class":79},[73,13505,1115],{"class":116},[73,13507,7895],{"class":79},[73,13509,1655],{"class":631},[73,13511,1658],{"class":87},[73,13513,1661],{"class":116},[73,13515,1664],{"class":87},[73,13517,1370],{"class":116},[73,13519,13520,13522,13524,13526,13528,13530,13532],{"class":75,"line":165},[73,13521,950],{"class":631},[73,13523,3360],{"class":87},[73,13525,956],{"class":631},[73,13527,959],{"class":79},[73,13529,977],{"class":116},[73,13531,5486],{"class":87},[73,13533,1370],{"class":116},[73,13535,13536],{"class":75,"line":178},[73,13537,130],{"emptyLinePlaceholder":129},[73,13539,13540,13542,13544,13546,13548,13550,13552,13554,13556,13558],{"class":75,"line":191},[73,13541,950],{"class":631},[73,13543,7919],{"class":87},[73,13545,956],{"class":631},[73,13547,4795],{"class":79},[73,13549,1033],{"class":116},[73,13551,632],{"class":631},[73,13553,7930],{"class":116},[73,13555,1929],{"class":631},[73,13557,1658],{"class":87},[73,13559,1370],{"class":116},[73,13561,13562,13564,13567,13569,13571,13573,13575,13578,13580,13583],{"class":75,"line":204},[73,13563,950],{"class":631},[73,13565,13566],{"class":87}," displayName",[73,13568,956],{"class":631},[73,13570,4795],{"class":79},[73,13572,1033],{"class":116},[73,13574,632],{"class":631},[73,13576,13577],{"class":116}," user.value?.name ",[73,13579,3106],{"class":631},[73,13581,13582],{"class":83}," 'Guest'",[73,13584,1370],{"class":116},[73,13586,13587],{"class":75,"line":217},[73,13588,130],{"emptyLinePlaceholder":129},[73,13590,13591,13593,13595,13598],{"class":75,"line":230},[73,13592,1802],{"class":631},[73,13594,939],{"class":631},[73,13596,13597],{"class":79}," fetchUser",[73,13599,945],{"class":116},[73,13601,13602,13605,13607],{"class":75,"line":243},[73,13603,13604],{"class":116}," loading.value ",[73,13606,991],{"class":631},[73,13608,1780],{"class":87},[73,13610,13611,13614],{"class":75,"line":256},[73,13612,13613],{"class":631}," try",[73,13615,268],{"class":116},[73,13617,13618,13620,13622,13624,13626,13628,13630,13633],{"class":75,"line":265},[73,13619,950],{"class":631},[73,13621,8439],{"class":87},[73,13623,956],{"class":631},[73,13625,1401],{"class":631},[73,13627,2088],{"class":79},[73,13629,977],{"class":116},[73,13631,13632],{"class":83},"'/api/user/me'",[73,13634,1370],{"class":116},[73,13636,13637,13639,13641],{"class":75,"line":271},[73,13638,7930],{"class":116},[73,13640,991],{"class":631},[73,13642,13643],{"class":116}," response\n",[73,13645,13646,13648,13651],{"class":75,"line":282},[73,13647,1147],{"class":116},[73,13649,13650],{"class":631},"finally",[73,13652,268],{"class":116},[73,13654,13655,13657,13659],{"class":75,"line":293},[73,13656,13604],{"class":116},[73,13658,991],{"class":631},[73,13660,13661],{"class":87}," false\n",[73,13663,13664],{"class":75,"line":304},[73,13665,1889],{"class":116},[73,13667,13668],{"class":75,"line":310},[73,13669,1889],{"class":116},[73,13671,13672],{"class":75,"line":315},[73,13673,130],{"emptyLinePlaceholder":129},[73,13675,13676,13678,13681],{"class":75,"line":325},[73,13677,939],{"class":631},[73,13679,13680],{"class":79}," logout",[73,13682,945],{"class":116},[73,13684,13685,13687,13689],{"class":75,"line":335},[73,13686,7930],{"class":116},[73,13688,991],{"class":631},[73,13690,1789],{"class":87},[73,13692,13693],{"class":75,"line":344},[73,13694,1889],{"class":116},[73,13696,13697],{"class":75,"line":349},[73,13698,130],{"emptyLinePlaceholder":129},[73,13700,13701,13703],{"class":75,"line":354},[73,13702,1084],{"class":631},[73,13704,13705],{"class":116}," { user, loading, isAuthenticated, displayName, fetchUser, logout }\n",[73,13707,13708],{"class":75,"line":363},[73,13709,1379],{"class":116},[15,13711,13712],{},"That is the entire store. No separate mutations. Actions modify state directly. The computed properties work exactly like Vue composables because this is just a Vue composable with a registration mechanism on top.",[22,13714,13716],{"id":13715},"options-style-vs-composable-style","Options Style vs Composable Style",[15,13718,13719],{},"Pinia supports two store definition styles. The options style looks familiar to Vuex users:",[64,13721,13723],{"className":97,"code":13722,"language":99,"meta":69,"style":69},"export const useCounterStore = defineStore('counter', {\n state: () => ({ count: 0 }),\n getters: {\n doubled: (state) => state.count * 2,\n },\n actions: {\n increment() {\n this.count++\n },\n },\n})\n",[59,13724,13725,13745,13760,13765,13787,13791,13796,13802,13813,13817,13821],{"__ignoreMap":69},[73,13726,13727,13729,13731,13734,13736,13738,13740,13743],{"class":75,"line":76},[73,13728,936],{"class":631},[73,13730,950],{"class":631},[73,13732,13733],{"class":87}," useCounterStore",[73,13735,956],{"class":631},[73,13737,8741],{"class":79},[73,13739,977],{"class":116},[73,13741,13742],{"class":83},"'counter'",[73,13744,2096],{"class":116},[73,13746,13747,13749,13751,13753,13756,13758],{"class":75,"line":110},[73,13748,9571],{"class":79},[73,13750,2647],{"class":116},[73,13752,632],{"class":631},[73,13754,13755],{"class":116}," ({ count: ",[73,13757,4964],{"class":87},[73,13759,3175],{"class":116},[73,13761,13762],{"class":75,"line":126},[73,13763,13764],{"class":116}," getters: {\n",[73,13766,13767,13769,13772,13774,13776,13778,13781,13783,13785],{"class":75,"line":133},[73,13768,4790],{"class":79},[73,13770,13771],{"class":116},": (",[73,13773,9538],{"class":624},[73,13775,1715],{"class":116},[73,13777,632],{"class":631},[73,13779,13780],{"class":116}," state.count ",[73,13782,4805],{"class":631},[73,13784,4808],{"class":87},[73,13786,154],{"class":116},[73,13788,13789],{"class":75,"line":142},[73,13790,307],{"class":116},[73,13792,13793],{"class":75,"line":157},[73,13794,13795],{"class":116}," actions: {\n",[73,13797,13798,13800],{"class":75,"line":165},[73,13799,4821],{"class":79},[73,13801,945],{"class":116},[73,13803,13804,13807,13810],{"class":75,"line":178},[73,13805,13806],{"class":87}," this",[73,13808,13809],{"class":116},".count",[73,13811,13812],{"class":631},"++\n",[73,13814,13815],{"class":75,"line":191},[73,13816,307],{"class":116},[73,13818,13819],{"class":75,"line":204},[73,13820,307],{"class":116},[73,13822,13823],{"class":75,"line":217},[73,13824,1379],{"class":116},[15,13826,13827,13828,710,13830,13832],{},"The composable style uses ",[59,13829,5471],{},[59,13831,1911],{},", and functions directly, as shown in the first example. I default to the composable style on all new projects. It has better TypeScript inference, it is more familiar if you are already using the Composition API, and it composes better with external composables.",[15,13834,13835],{},"Use the options style only if you have team members who are more comfortable with the Vuex mental model and the transition friction is a concern.",[22,13837,13839],{"id":13838},"typescript-integration","TypeScript Integration",[15,13841,13842,13843,3291,13845,13847],{},"Pinia's TypeScript support is one of its strongest selling points. The composable style store infers types automatically from your ",[59,13844,5471],{},[59,13846,1911],{}," declarations. You get full autocomplete and type checking when using the store in components:",[64,13849,13851],{"className":1101,"code":13850,"language":1103,"meta":69,"style":69},"\u003Cscript setup lang=\"ts\">\nimport { useUserStore } from '~/stores/user'\n\nConst userStore = useUserStore()\n\n// userStore.user is typed as User | null\n// userStore.isAuthenticated is typed as boolean\n// userStore.fetchUser is typed as () => Promise\u003Cvoid>\n\u003C/script>\n",[59,13852,13853,13869,13881,13885,13896,13900,13905,13910,13915],{"__ignoreMap":69},[73,13854,13855,13857,13859,13861,13863,13865,13867],{"class":75,"line":76},[73,13856,1115],{"class":116},[73,13858,1119],{"class":1118},[73,13860,1122],{"class":79},[73,13862,1125],{"class":79},[73,13864,991],{"class":116},[73,13866,1130],{"class":83},[73,13868,1133],{"class":116},[73,13870,13871,13873,13876,13878],{"class":75,"line":110},[73,13872,2143],{"class":631},[73,13874,13875],{"class":116}," { useUserStore } ",[73,13877,2149],{"class":631},[73,13879,13880],{"class":83}," '~/stores/user'\n",[73,13882,13883],{"class":75,"line":126},[73,13884,130],{"emptyLinePlaceholder":129},[73,13886,13887,13890,13892,13894],{"class":75,"line":133},[73,13888,13889],{"class":116},"Const userStore ",[73,13891,991],{"class":631},[73,13893,13479],{"class":79},[73,13895,1154],{"class":116},[73,13897,13898],{"class":75,"line":142},[73,13899,130],{"emptyLinePlaceholder":129},[73,13901,13902],{"class":75,"line":157},[73,13903,13904],{"class":106},"// userStore.user is typed as User | null\n",[73,13906,13907],{"class":75,"line":165},[73,13908,13909],{"class":106},"// userStore.isAuthenticated is typed as boolean\n",[73,13911,13912],{"class":75,"line":178},[73,13913,13914],{"class":106},"// userStore.fetchUser is typed as () => Promise\u003Cvoid>\n",[73,13916,13917,13919,13921],{"class":75,"line":191},[73,13918,1159],{"class":116},[73,13920,1119],{"class":1118},[73,13922,1133],{"class":116},[15,13924,13925],{},"No manual type declarations needed. The store is fully typed from the implementation.",[15,13927,13928],{},"For stores with complex state shapes, define interfaces explicitly:",[64,13930,13932],{"className":97,"code":13931,"language":99,"meta":69,"style":69},"interface CartItem {\n productId: string\n quantity: number\n price: number\n}\n\nInterface CartState {\n items: CartItem[]\n couponCode: string | null\n}\n\nExport const useCartStore = defineStore('cart', () => {\n const items = ref\u003CCartItem[]>([])\n const couponCode = ref\u003Cstring | null>(null)\n\n const total = computed(() =>\n items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)\n )\n\n function addItem(item: CartItem) {\n const existing = items.value.find(i => i.productId === item.productId)\n if (existing) {\n existing.quantity += item.quantity\n } else {\n items.value.push(item)\n }\n }\n\n return { items, couponCode, total, addItem }\n})\n",[59,13933,13934,13942,13951,13961,13970,13974,13978,13983,13990,14002,14006,14010,14032,14048,14072,14076,14090,14122,14126,14130,14146,14175,14182,14193,14202,14211,14215,14219,14223,14230],{"__ignoreMap":69},[73,13935,13936,13938,13940],{"class":75,"line":76},[73,13937,8155],{"class":631},[73,13939,8856],{"class":79},[73,13941,268],{"class":116},[73,13943,13944,13947,13949],{"class":75,"line":110},[73,13945,13946],{"class":624}," productId",[73,13948,2425],{"class":631},[73,13950,8170],{"class":87},[73,13952,13953,13956,13958],{"class":75,"line":126},[73,13954,13955],{"class":624}," quantity",[73,13957,2425],{"class":631},[73,13959,13960],{"class":87}," number\n",[73,13962,13963,13966,13968],{"class":75,"line":133},[73,13964,13965],{"class":624}," price",[73,13967,2425],{"class":631},[73,13969,13960],{"class":87},[73,13971,13972],{"class":75,"line":142},[73,13973,1098],{"class":116},[73,13975,13976],{"class":75,"line":157},[73,13977,130],{"emptyLinePlaceholder":129},[73,13979,13980],{"class":75,"line":165},[73,13981,13982],{"class":116},"Interface CartState {\n",[73,13984,13985,13987],{"class":75,"line":178},[73,13986,8759],{"class":79},[73,13988,13989],{"class":116},": CartItem[]\n",[73,13991,13992,13995,13998,14000],{"class":75,"line":191},[73,13993,13994],{"class":79}," couponCode",[73,13996,13997],{"class":116},": string ",[73,13999,10733],{"class":631},[73,14001,1789],{"class":87},[73,14003,14004],{"class":75,"line":204},[73,14005,1098],{"class":116},[73,14007,14008],{"class":75,"line":217},[73,14009,130],{"emptyLinePlaceholder":129},[73,14011,14012,14014,14016,14018,14020,14022,14024,14026,14028,14030],{"class":75,"line":230},[73,14013,2208],{"class":116},[73,14015,1138],{"class":631},[73,14017,5710],{"class":87},[73,14019,956],{"class":631},[73,14021,8741],{"class":79},[73,14023,977],{"class":116},[73,14025,8746],{"class":83},[73,14027,983],{"class":116},[73,14029,632],{"class":631},[73,14031,268],{"class":116},[73,14033,14034,14036,14038,14040,14042,14044,14046],{"class":75,"line":243},[73,14035,950],{"class":631},[73,14037,8759],{"class":87},[73,14039,956],{"class":631},[73,14041,959],{"class":79},[73,14043,1115],{"class":116},[73,14045,8768],{"class":79},[73,14047,8771],{"class":116},[73,14049,14050,14052,14054,14056,14058,14060,14062,14064,14066,14068,14070],{"class":75,"line":256},[73,14051,950],{"class":631},[73,14053,13994],{"class":87},[73,14055,956],{"class":631},[73,14057,959],{"class":79},[73,14059,1115],{"class":116},[73,14061,7825],{"class":87},[73,14063,1655],{"class":631},[73,14065,1658],{"class":87},[73,14067,1661],{"class":116},[73,14069,1664],{"class":87},[73,14071,1370],{"class":116},[73,14073,14074],{"class":75,"line":265},[73,14075,130],{"emptyLinePlaceholder":129},[73,14077,14078,14080,14082,14084,14086,14088],{"class":75,"line":271},[73,14079,950],{"class":631},[73,14081,8782],{"class":87},[73,14083,956],{"class":631},[73,14085,4795],{"class":79},[73,14087,1033],{"class":116},[73,14089,2595],{"class":631},[73,14091,14092,14094,14096,14098,14100,14102,14104,14106,14108,14110,14112,14114,14116,14118,14120],{"class":75,"line":282},[73,14093,8795],{"class":116},[73,14095,8798],{"class":79},[73,14097,8801],{"class":116},[73,14099,8804],{"class":624},[73,14101,710],{"class":116},[73,14103,8809],{"class":624},[73,14105,1715],{"class":116},[73,14107,632],{"class":631},[73,14109,8816],{"class":116},[73,14111,8819],{"class":631},[73,14113,8822],{"class":116},[73,14115,4805],{"class":631},[73,14117,8827],{"class":116},[73,14119,4964],{"class":87},[73,14121,1370],{"class":116},[73,14123,14124],{"class":75,"line":293},[73,14125,8836],{"class":116},[73,14127,14128],{"class":75,"line":304},[73,14129,130],{"emptyLinePlaceholder":129},[73,14131,14132,14134,14136,14138,14140,14142,14144],{"class":75,"line":310},[73,14133,939],{"class":631},[73,14135,8847],{"class":79},[73,14137,977],{"class":116},[73,14139,8809],{"class":624},[73,14141,2425],{"class":631},[73,14143,8856],{"class":79},[73,14145,1349],{"class":116},[73,14147,14148,14150,14153,14155,14157,14159,14161,14164,14167,14170,14172],{"class":75,"line":315},[73,14149,950],{"class":631},[73,14151,14152],{"class":87}," existing",[73,14154,956],{"class":631},[73,14156,8795],{"class":116},[73,14158,6407],{"class":79},[73,14160,977],{"class":116},[73,14162,14163],{"class":624},"i",[73,14165,14166],{"class":631}," =>",[73,14168,14169],{"class":116}," i.productId ",[73,14171,638],{"class":631},[73,14173,14174],{"class":116}," item.productId)\n",[73,14176,14177,14179],{"class":75,"line":325},[73,14178,1814],{"class":631},[73,14180,14181],{"class":116}," (existing) {\n",[73,14183,14184,14187,14190],{"class":75,"line":335},[73,14185,14186],{"class":116}," existing.quantity ",[73,14188,14189],{"class":631},"+=",[73,14191,14192],{"class":116}," item.quantity\n",[73,14194,14195,14197,14200],{"class":75,"line":344},[73,14196,1147],{"class":116},[73,14198,14199],{"class":631},"else",[73,14201,268],{"class":116},[73,14203,14204,14206,14208],{"class":75,"line":349},[73,14205,8795],{"class":116},[73,14207,7487],{"class":79},[73,14209,14210],{"class":116},"(item)\n",[73,14212,14213],{"class":75,"line":354},[73,14214,1889],{"class":116},[73,14216,14217],{"class":75,"line":363},[73,14218,1889],{"class":116},[73,14220,14221],{"class":75,"line":372},[73,14222,130],{"emptyLinePlaceholder":129},[73,14224,14225,14227],{"class":75,"line":381},[73,14226,1084],{"class":631},[73,14228,14229],{"class":116}," { items, couponCode, total, addItem }\n",[73,14231,14232],{"class":75,"line":392},[73,14233,1379],{"class":116},[22,14235,14237],{"id":14236},"composing-stores","Composing Stores",[15,14239,14240],{},"One of Pinia's design wins is how stores compose with each other. You can use one store inside another:",[64,14242,14244],{"className":97,"code":14243,"language":99,"meta":69,"style":69},"export const useOrderStore = defineStore('order', () => {\n const cartStore = useCartStore()\n const userStore = useUserStore()\n\n async function submitOrder() {\n if (!userStore.isAuthenticated) {\n throw new Error('Must be logged in to submit order')\n }\n\n const order = {\n userId: userStore.user!.id,\n items: cartStore.items,\n total: cartStore.total,\n }\n\n await $fetch('/api/orders', { method: 'POST', body: order })\n cartStore.items = []\n }\n\n return { submitOrder }\n})\n",[59,14245,14246,14270,14283,14296,14300,14311,14322,14337,14341,14345,14355,14365,14370,14375,14379,14383,14402,14411,14415,14419,14426],{"__ignoreMap":69},[73,14247,14248,14250,14252,14255,14257,14259,14261,14264,14266,14268],{"class":75,"line":76},[73,14249,936],{"class":631},[73,14251,950],{"class":631},[73,14253,14254],{"class":87}," useOrderStore",[73,14256,956],{"class":631},[73,14258,8741],{"class":79},[73,14260,977],{"class":116},[73,14262,14263],{"class":83},"'order'",[73,14265,983],{"class":116},[73,14267,632],{"class":631},[73,14269,268],{"class":116},[73,14271,14272,14274,14277,14279,14281],{"class":75,"line":110},[73,14273,950],{"class":631},[73,14275,14276],{"class":87}," cartStore",[73,14278,956],{"class":631},[73,14280,5710],{"class":79},[73,14282,1154],{"class":116},[73,14284,14285,14287,14290,14292,14294],{"class":75,"line":126},[73,14286,950],{"class":631},[73,14288,14289],{"class":87}," userStore",[73,14291,956],{"class":631},[73,14293,13479],{"class":79},[73,14295,1154],{"class":116},[73,14297,14298],{"class":75,"line":133},[73,14299,130],{"emptyLinePlaceholder":129},[73,14301,14302,14304,14306,14309],{"class":75,"line":142},[73,14303,1802],{"class":631},[73,14305,939],{"class":631},[73,14307,14308],{"class":79}," submitOrder",[73,14310,945],{"class":116},[73,14312,14313,14315,14317,14319],{"class":75,"line":157},[73,14314,1814],{"class":631},[73,14316,1817],{"class":116},[73,14318,1820],{"class":631},[73,14320,14321],{"class":116},"userStore.isAuthenticated) {\n",[73,14323,14324,14326,14328,14330,14332,14335],{"class":75,"line":165},[73,14325,10074],{"class":631},[73,14327,9608],{"class":631},[73,14329,9202],{"class":79},[73,14331,977],{"class":116},[73,14333,14334],{"class":83},"'Must be logged in to submit order'",[73,14336,1370],{"class":116},[73,14338,14339],{"class":75,"line":178},[73,14340,1889],{"class":116},[73,14342,14343],{"class":75,"line":191},[73,14344,130],{"emptyLinePlaceholder":129},[73,14346,14347,14349,14351,14353],{"class":75,"line":204},[73,14348,950],{"class":631},[73,14350,11714],{"class":87},[73,14352,956],{"class":631},[73,14354,268],{"class":116},[73,14356,14357,14360,14362],{"class":75,"line":217},[73,14358,14359],{"class":116}," userId: userStore.user",[73,14361,1820],{"class":631},[73,14363,14364],{"class":116},".id,\n",[73,14366,14367],{"class":75,"line":230},[73,14368,14369],{"class":116}," items: cartStore.items,\n",[73,14371,14372],{"class":75,"line":243},[73,14373,14374],{"class":116}," total: cartStore.total,\n",[73,14376,14377],{"class":75,"line":256},[73,14378,1889],{"class":116},[73,14380,14381],{"class":75,"line":265},[73,14382,130],{"emptyLinePlaceholder":129},[73,14384,14385,14387,14389,14391,14394,14397,14399],{"class":75,"line":271},[73,14386,1401],{"class":631},[73,14388,2088],{"class":79},[73,14390,977],{"class":116},[73,14392,14393],{"class":83},"'/api/orders'",[73,14395,14396],{"class":116},", { method: ",[73,14398,2104],{"class":83},[73,14400,14401],{"class":116},", body: order })\n",[73,14403,14404,14407,14409],{"class":75,"line":282},[73,14405,14406],{"class":116}," cartStore.items ",[73,14408,991],{"class":631},[73,14410,8685],{"class":116},[73,14412,14413],{"class":75,"line":293},[73,14414,1889],{"class":116},[73,14416,14417],{"class":75,"line":304},[73,14418,130],{"emptyLinePlaceholder":129},[73,14420,14421,14423],{"class":75,"line":310},[73,14422,1084],{"class":631},[73,14424,14425],{"class":116}," { submitOrder }\n",[73,14427,14428],{"class":75,"line":315},[73,14429,1379],{"class":116},[15,14431,14432],{},"In Vuex, accessing one store from another required namespaced module access that felt clunky. In Pinia, it is just a function call.",[22,14434,14436],{"id":14435},"state-persistence","State Persistence",[15,14438,14439,14440,14443],{},"For state that should survive page refreshes — authentication tokens, user preferences, shopping cart contents — use the ",[59,14441,14442],{},"pinia-plugin-persistedstate"," package:",[64,14445,14447],{"className":97,"code":14446,"language":99,"meta":69,"style":69},"// plugins/pinia.ts\nimport { createPinia } from 'pinia'\nimport piniaPluginPersistedstate from 'pinia-plugin-persistedstate'\n\nConst pinia = createPinia()\npinia.use(piniaPluginPersistedstate)\n",[59,14448,14449,14454,14465,14477,14481,14493],{"__ignoreMap":69},[73,14450,14451],{"class":75,"line":76},[73,14452,14453],{"class":106},"// plugins/pinia.ts\n",[73,14455,14456,14458,14461,14463],{"class":75,"line":110},[73,14457,2143],{"class":631},[73,14459,14460],{"class":116}," { createPinia } ",[73,14462,2149],{"class":631},[73,14464,5622],{"class":83},[73,14466,14467,14469,14472,14474],{"class":75,"line":126},[73,14468,2143],{"class":631},[73,14470,14471],{"class":116}," piniaPluginPersistedstate ",[73,14473,2149],{"class":631},[73,14475,14476],{"class":83}," 'pinia-plugin-persistedstate'\n",[73,14478,14479],{"class":75,"line":133},[73,14480,130],{"emptyLinePlaceholder":129},[73,14482,14483,14486,14488,14491],{"class":75,"line":142},[73,14484,14485],{"class":116},"Const pinia ",[73,14487,991],{"class":631},[73,14489,14490],{"class":79}," createPinia",[73,14492,1154],{"class":116},[73,14494,14495,14498,14501],{"class":75,"line":157},[73,14496,14497],{"class":116},"pinia.",[73,14499,14500],{"class":79},"use",[73,14502,14503],{"class":116},"(piniaPluginPersistedstate)\n",[15,14505,14506],{},"Then enable persistence per store:",[64,14508,14510],{"className":97,"code":14509,"language":99,"meta":69,"style":69},"export const useAuthStore = defineStore('auth', () => {\n const token = ref\u003Cstring | null>(null)\n const refreshToken = ref\u003Cstring | null>(null)\n\n return { token, refreshToken }\n}, {\n persist: {\n key: 'auth',\n storage: persistedState.localStorage,\n // Only persist these specific fields\n pick: ['token', 'refreshToken'],\n },\n})\n",[59,14511,14512,14536,14561,14586,14590,14597,14602,14607,14616,14621,14626,14641,14645],{"__ignoreMap":69},[73,14513,14514,14516,14518,14521,14523,14525,14527,14530,14532,14534],{"class":75,"line":76},[73,14515,936],{"class":631},[73,14517,950],{"class":631},[73,14519,14520],{"class":87}," useAuthStore",[73,14522,956],{"class":631},[73,14524,8741],{"class":79},[73,14526,977],{"class":116},[73,14528,14529],{"class":83},"'auth'",[73,14531,983],{"class":116},[73,14533,632],{"class":631},[73,14535,268],{"class":116},[73,14537,14538,14540,14543,14545,14547,14549,14551,14553,14555,14557,14559],{"class":75,"line":110},[73,14539,950],{"class":631},[73,14541,14542],{"class":87}," token",[73,14544,956],{"class":631},[73,14546,959],{"class":79},[73,14548,1115],{"class":116},[73,14550,7825],{"class":87},[73,14552,1655],{"class":631},[73,14554,1658],{"class":87},[73,14556,1661],{"class":116},[73,14558,1664],{"class":87},[73,14560,1370],{"class":116},[73,14562,14563,14565,14568,14570,14572,14574,14576,14578,14580,14582,14584],{"class":75,"line":126},[73,14564,950],{"class":631},[73,14566,14567],{"class":87}," refreshToken",[73,14569,956],{"class":631},[73,14571,959],{"class":79},[73,14573,1115],{"class":116},[73,14575,7825],{"class":87},[73,14577,1655],{"class":631},[73,14579,1658],{"class":87},[73,14581,1661],{"class":116},[73,14583,1664],{"class":87},[73,14585,1370],{"class":116},[73,14587,14588],{"class":75,"line":133},[73,14589,130],{"emptyLinePlaceholder":129},[73,14591,14592,14594],{"class":75,"line":142},[73,14593,1084],{"class":631},[73,14595,14596],{"class":116}," { token, refreshToken }\n",[73,14598,14599],{"class":75,"line":157},[73,14600,14601],{"class":116},"}, {\n",[73,14603,14604],{"class":75,"line":165},[73,14605,14606],{"class":116}," persist: {\n",[73,14608,14609,14612,14614],{"class":75,"line":178},[73,14610,14611],{"class":116}," key: ",[73,14613,14529],{"class":83},[73,14615,154],{"class":116},[73,14617,14618],{"class":75,"line":191},[73,14619,14620],{"class":116}," storage: persistedState.localStorage,\n",[73,14622,14623],{"class":75,"line":204},[73,14624,14625],{"class":106}," // Only persist these specific fields\n",[73,14627,14628,14631,14634,14636,14639],{"class":75,"line":217},[73,14629,14630],{"class":116}," pick: [",[73,14632,14633],{"class":83},"'token'",[73,14635,710],{"class":116},[73,14637,14638],{"class":83},"'refreshToken'",[73,14640,123],{"class":116},[73,14642,14643],{"class":75,"line":230},[73,14644,307],{"class":116},[73,14646,14647],{"class":75,"line":243},[73,14648,1379],{"class":116},[15,14650,14651],{},"Be thoughtful about what you persist. Persisting large objects or derived state creates synchronization bugs. Persist only the minimal state needed to restore sessions.",[22,14653,14655],{"id":14654},"when-not-to-use-pinia","When Not to Use Pinia",[15,14657,14658],{},"This is the conversation I have with developers who reach for a store for everything. Not all state belongs in a store.",[15,14660,14661,14662,14664],{},"Local component state — whether a dropdown is open, which tab is active, the current value of a text input — belongs in ",[59,14663,5471],{}," in the component. If that state is never shared with other components and does not need to survive navigation, keep it local.",[15,14666,14667,14668,14671],{},"Server state — data fetched from an API — often belongs in a data fetching layer (TanStack Query's Vue wrapper, or Nuxt's ",[59,14669,14670],{},"useAsyncData",") rather than a Pinia store. Stores do not have built-in cache invalidation, stale-while-revalidate behavior, or request deduplication. If your store is mostly just mirroring API responses, a proper data fetching library handles that better.",[15,14673,14674],{},"Pinia is the right tool for genuinely shared application state: authentication, user preferences, shopping cart, multi-step form state that spans multiple routes, real-time connection state.",[22,14676,5582],{"id":5581},[15,14678,14679,14680,14682],{},"Stores are easy to test because they are just functions. Use Vitest with ",[59,14681,5588],{}," from the testing utilities:",[64,14684,14686],{"className":97,"code":14685,"language":99,"meta":69,"style":69},"import { setActivePinia, createPinia } from 'pinia'\nimport { useCartStore } from './cart'\n\nDescribe('CartStore', () => {\n beforeEach(() => {\n setActivePinia(createPinia())\n })\n\n it('adds items to cart', () => {\n const cart = useCartStore()\n cart.addItem({ productId: 'p1', quantity: 1, price: 29.99 })\n expect(cart.items).toHaveLength(1)\n expect(cart.total).toBe(29.99)\n })\n\n it('increments quantity for duplicate items', () => {\n const cart = useCartStore()\n cart.addItem({ productId: 'p1', quantity: 1, price: 29.99 })\n cart.addItem({ productId: 'p1', quantity: 2, price: 29.99 })\n expect(cart.items).toHaveLength(1)\n expect(cart.items[0].quantity).toBe(3)\n })\n})\n",[59,14687,14688,14698,14709,14713,14727,14737,14747,14751,14755,14770,14782,14802,14816,14830,14834,14838,14852,14864,14884,14904,14918,14936,14940],{"__ignoreMap":69},[73,14689,14690,14692,14694,14696],{"class":75,"line":76},[73,14691,2143],{"class":631},[73,14693,5617],{"class":116},[73,14695,2149],{"class":631},[73,14697,5622],{"class":83},[73,14699,14700,14702,14704,14706],{"class":75,"line":110},[73,14701,2143],{"class":631},[73,14703,5629],{"class":116},[73,14705,2149],{"class":631},[73,14707,14708],{"class":83}," './cart'\n",[73,14710,14711],{"class":75,"line":126},[73,14712,130],{"emptyLinePlaceholder":129},[73,14714,14715,14717,14719,14721,14723,14725],{"class":75,"line":133},[73,14716,4904],{"class":79},[73,14718,977],{"class":116},[73,14720,5647],{"class":83},[73,14722,983],{"class":116},[73,14724,632],{"class":631},[73,14726,268],{"class":116},[73,14728,14729,14731,14733,14735],{"class":75,"line":142},[73,14730,5361],{"class":79},[73,14732,1033],{"class":116},[73,14734,632],{"class":631},[73,14736,268],{"class":116},[73,14738,14739,14741,14743,14745],{"class":75,"line":157},[73,14740,5668],{"class":79},[73,14742,977],{"class":116},[73,14744,5588],{"class":79},[73,14746,5675],{"class":116},[73,14748,14749],{"class":75,"line":165},[73,14750,997],{"class":116},[73,14752,14753],{"class":75,"line":178},[73,14754,130],{"emptyLinePlaceholder":129},[73,14756,14757,14759,14761,14764,14766,14768],{"class":75,"line":191},[73,14758,4920],{"class":79},[73,14760,977],{"class":116},[73,14762,14763],{"class":83},"'adds items to cart'",[73,14765,983],{"class":116},[73,14767,632],{"class":631},[73,14769,268],{"class":116},[73,14771,14772,14774,14776,14778,14780],{"class":75,"line":204},[73,14773,950],{"class":631},[73,14775,5705],{"class":87},[73,14777,956],{"class":631},[73,14779,5710],{"class":79},[73,14781,1154],{"class":116},[73,14783,14784,14786,14788,14790,14792,14794,14796,14798,14800],{"class":75,"line":217},[73,14785,5783],{"class":116},[73,14787,5786],{"class":79},[73,14789,5789],{"class":116},[73,14791,5792],{"class":83},[73,14793,5807],{"class":116},[73,14795,5086],{"class":87},[73,14797,5801],{"class":116},[73,14799,5804],{"class":87},[73,14801,997],{"class":116},[73,14803,14804,14806,14808,14810,14812,14814],{"class":75,"line":230},[73,14805,4953],{"class":79},[73,14807,5719],{"class":116},[73,14809,5722],{"class":79},[73,14811,977],{"class":116},[73,14813,5086],{"class":87},[73,14815,1370],{"class":116},[73,14817,14818,14820,14822,14824,14826,14828],{"class":75,"line":243},[73,14819,4953],{"class":79},[73,14821,5735],{"class":116},[73,14823,4959],{"class":79},[73,14825,977],{"class":116},[73,14827,5804],{"class":87},[73,14829,1370],{"class":116},[73,14831,14832],{"class":75,"line":256},[73,14833,997],{"class":116},[73,14835,14836],{"class":75,"line":265},[73,14837,130],{"emptyLinePlaceholder":129},[73,14839,14840,14842,14844,14846,14848,14850],{"class":75,"line":271},[73,14841,4920],{"class":79},[73,14843,977],{"class":116},[73,14845,5856],{"class":83},[73,14847,983],{"class":116},[73,14849,632],{"class":631},[73,14851,268],{"class":116},[73,14853,14854,14856,14858,14860,14862],{"class":75,"line":282},[73,14855,950],{"class":631},[73,14857,5705],{"class":87},[73,14859,956],{"class":631},[73,14861,5710],{"class":79},[73,14863,1154],{"class":116},[73,14865,14866,14868,14870,14872,14874,14876,14878,14880,14882],{"class":75,"line":293},[73,14867,5783],{"class":116},[73,14869,5786],{"class":79},[73,14871,5789],{"class":116},[73,14873,5792],{"class":83},[73,14875,5807],{"class":116},[73,14877,5086],{"class":87},[73,14879,5801],{"class":116},[73,14881,5804],{"class":87},[73,14883,997],{"class":116},[73,14885,14886,14888,14890,14892,14894,14896,14898,14900,14902],{"class":75,"line":304},[73,14887,5783],{"class":116},[73,14889,5786],{"class":79},[73,14891,5789],{"class":116},[73,14893,5792],{"class":83},[73,14895,5807],{"class":116},[73,14897,5922],{"class":87},[73,14899,5801],{"class":116},[73,14901,5804],{"class":87},[73,14903,997],{"class":116},[73,14905,14906,14908,14910,14912,14914,14916],{"class":75,"line":310},[73,14907,4953],{"class":79},[73,14909,5719],{"class":116},[73,14911,5722],{"class":79},[73,14913,977],{"class":116},[73,14915,5086],{"class":87},[73,14917,1370],{"class":116},[73,14919,14920,14922,14924,14926,14928,14930,14932,14934],{"class":75,"line":315},[73,14921,4953],{"class":79},[73,14923,5945],{"class":116},[73,14925,4964],{"class":87},[73,14927,5950],{"class":116},[73,14929,4959],{"class":79},[73,14931,977],{"class":116},[73,14933,5139],{"class":87},[73,14935,1370],{"class":116},[73,14937,14938],{"class":75,"line":325},[73,14939,997],{"class":116},[73,14941,14942],{"class":75,"line":335},[73,14943,1379],{"class":116},[15,14945,14946,14947,14949],{},"No mocking needed for the store itself. Mock the API calls inside actions using ",[59,14948,5276],{}," or MSW. This gives you fast, isolated tests that cover the logic without touching the network.",[22,14951,14953],{"id":14952},"devtools-integration","Devtools Integration",[15,14955,14956],{},"Pinia ships with Vue Devtools integration out of the box. Every store is inspectable in the devtools panel — you can see current state, trigger actions manually, and time-travel through state changes. This integration works without any configuration, which is a welcome improvement over setting up Vuex devtools.",[22,14958,14960],{"id":14959},"migrating-from-vuex","Migrating From Vuex",[15,14962,14963],{},"If you are on a Vue 3 project still using Vuex, the migration to Pinia is straightforward but takes time. Do not do a full rewrite. Instead, convert stores one at a time as you work on related features. Pinia and Vuex can coexist in the same application during migration.",[15,14965,14966],{},"Map the Vuex concepts: state becomes refs, getters become computed, mutations become direct state assignments inside actions, actions stay actions. The biggest conceptual shift is that mutations disappear — actions can now directly modify state.",[15,14968,14969],{},"Pinia is the Vue store I have been waiting for since I started building Vue applications. It respects developer time, TypeScript, and the Composition API mental model. If you are building anything in Vue 3, this is the state management solution to reach for.",[2326,14971],{},[15,14973,14974,14975,2274],{},"Designing a Nuxt or Vue 3 application and want help thinking through your state management architecture? Let's talk: ",[2332,14976,2337],{"href":2334,"rel":14977},[2336],[2326,14979],{},[22,14981,2343],{"id":2342},[2304,14983,14984,14990,14996,15002],{},[2307,14985,14986],{},[2332,14987,14989],{"href":14988},"/blog/vue-3-composables-guide","Vue 3 Composables: The Reusability Pattern That Changes Everything",[2307,14991,14992],{},[2332,14993,14995],{"href":14994},"/blog/vue-3-composition-api-guide","Vue 3 Composition API: A Practical Guide With Real Examples",[2307,14997,14998],{},[2332,14999,15001],{"href":15000},"/blog/vue-3-vs-react-2026","Vue 3 vs React in 2026: Choosing the Right Framework for Your Project",[2307,15003,15004],{},[2332,15005,15007],{"href":15006},"/blog/nuxt-4-features-guide","Nuxt 4: What Changed and Why It Matters",[2371,15009,15010],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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 pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":69,"searchDepth":126,"depth":126,"links":15012},[15013,15014,15015,15016,15017,15018,15019,15020,15021,15022],{"id":13426,"depth":110,"text":13427},{"id":13715,"depth":110,"text":13716},{"id":13838,"depth":110,"text":13839},{"id":14236,"depth":110,"text":14237},{"id":14435,"depth":110,"text":14436},{"id":14654,"depth":110,"text":14655},{"id":5581,"depth":110,"text":5582},{"id":14952,"depth":110,"text":14953},{"id":14959,"depth":110,"text":14960},{"id":2342,"depth":110,"text":2343},"A complete guide to Pinia for Vue 3 — store patterns, TypeScript integration, composable-style stores, persistence, and when to reach for Pinia vs local state.",[15025,15026],"Pinia state management","Vue 3 state management",{},"/blog/pinia-state-management-guide",{"title":13414,"description":15023},"blog/pinia-state-management-guide",[15032,15033,15034],"Vue","Pinia","State Management","kRbzUP6Kmpb9S2IE4RSFt0FgNNX8dreieHKbkI5eyC4",{"id":15037,"title":15038,"author":15039,"body":15040,"category":4386,"date":2386,"description":15310,"extension":2388,"featured":2389,"image":2390,"keywords":15311,"meta":15317,"navigation":129,"path":15318,"readTime":191,"seo":15319,"stem":15320,"tags":15321,"__hash__":15324},"blog/blog/platform-engineering-explained.md","Platform Engineering Explained (And Why It's Not Just DevOps)",{"name":9,"bio":10},{"type":12,"value":15041,"toc":15300},[15042,15046,15049,15052,15055,15057,15061,15064,15067,15070,15072,15076,15079,15085,15091,15097,15103,15109,15111,15115,15118,15121,15124,15127,15147,15150,15152,15156,15159,15162,15165,15171,15177,15183,15189,15191,15195,15198,15204,15210,15216,15222,15225,15227,15231,15234,15240,15246,15252,15258,15260,15263,15265,15272,15274,15276],[22,15043,15045],{"id":15044},"the-confusion-is-understandable","The Confusion Is Understandable",[15,15047,15048],{},"Platform engineering has become one of the most frequently discussed topics in engineering leadership conversations over the last three years. It's also one of the most confused. Ask five people what a platform team does and you'll get answers ranging from \"they manage Kubernetes\" to \"they're basically DevOps\" to \"they're the people who build our internal tools.\"",[15,15050,15051],{},"All of those answers contain partial truths, which is part of the problem. Platform engineering emerged from DevOps, uses many of the same tools as DevOps, and involves infrastructure work that looks like traditional operations. But treating it as a synonym for DevOps misses what makes it distinctly valuable.",[15,15053,15054],{},"Here's a clear-eyed explanation of what platform engineering actually is, what problems it solves, and when an organization needs it.",[2326,15056],{},[22,15058,15060],{"id":15059},"what-platform-engineering-actually-is","What Platform Engineering Actually Is",[15,15062,15063],{},"Platform engineering is the discipline of building and maintaining an internal developer platform (IDP) — a curated set of tools, services, workflows, and abstractions that application developers use to build, test, deploy, and operate software.",[15,15065,15066],{},"The platform team's customer is not the end user of the product. The platform team's customer is the engineering team building the product. Every decision the platform team makes is evaluated by one criterion: does this make product developers more productive, more autonomous, and more effective?",[15,15068,15069],{},"This distinction is fundamental. A DevOps team (in the traditional sense) focuses on deployment pipelines, infrastructure provisioning, and operational stability. These are infrastructure concerns. A platform team focuses on developer experience — reducing the cognitive load on application engineers so they can focus on business logic rather than infrastructure.",[2326,15071],{},[22,15073,15075],{"id":15074},"the-internal-developer-platform","The Internal Developer Platform",[15,15077,15078],{},"An internal developer platform (IDP) is the product the platform team builds. It typically includes:",[15,15080,15081,15084],{},[32,15082,15083],{},"Self-service infrastructure provisioning."," Developers can request a new service, database, message queue, or environment without filing a ticket and waiting for an ops team. The platform provides a catalog of approved, pre-configured options that developers can provision in minutes.",[15,15086,15087,15090],{},[32,15088,15089],{},"Standardized deployment pipelines."," Every service deploys the same way. There's a Golden Path — a well-lit, supported route from code to production — and developers don't need to understand the underlying CI/CD machinery to use it. The platform team maintains the pipeline; application teams use it.",[15,15092,15093,15096],{},[32,15094,15095],{},"Observability by default."," Every service deployed through the platform automatically gets logging, metrics, distributed tracing, and alerting configured. Developers don't need to instrument their services for basic observability — they get it for free.",[15,15098,15099,15102],{},[32,15100,15101],{},"Developer portal."," A single place to find service catalogs, documentation, deployment status, on-call schedules, incident history, and runbooks. The platform reduces the time developers spend searching for information about the system they're operating in.",[15,15104,15105,15108],{},[32,15106,15107],{},"Environment management."," Production-like environments available on demand for development and testing, without the friction of requesting them from an operations team.",[2326,15110],{},[22,15112,15114],{"id":15113},"the-golden-path","The Golden Path",[15,15116,15117],{},"The Golden Path is one of the most important concepts in platform engineering and the one most worth explaining in detail.",[15,15119,15120],{},"A Golden Path is a pre-built, opinionated, supported route for building and deploying a particular type of software. It's not a mandate — developers can deviate from it — but it's the path with support, documentation, and examples. Following the Golden Path means your service will automatically get security scanning, observability, compliance controls, and deployment automation. Deviating means you own those concerns yourself.",[15,15122,15123],{},"Spotify popularized the term, and the concept captures something important: a platform doesn't work by enforcing rules. It works by making the right path so easy and well-supported that engineers choose it voluntarily, because the alternative — figuring out all of those concerns yourself — costs more than the flexibility gained.",[15,15125,15126],{},"Golden Paths typically cover:",[2304,15128,15129,15132,15135,15138,15141,15144],{},[2307,15130,15131],{},"Service scaffolding (templates that produce a new service with the correct structure, dependencies, and configuration)",[2307,15133,15134],{},"CI/CD pipeline configuration",[2307,15136,15137],{},"Container build and registry",[2307,15139,15140],{},"Infrastructure-as-code templates for common resources",[2307,15142,15143],{},"Secret management patterns",[2307,15145,15146],{},"Testing patterns and integration with CI",[15,15148,15149],{},"A well-designed Golden Path removes dozens of decisions from each new service and ensures every service deployed through it meets your organization's standards by default.",[2326,15151],{},[22,15153,15155],{"id":15154},"platform-engineering-vs-devops-the-real-distinction","Platform Engineering vs DevOps: The Real Distinction",[15,15157,15158],{},"DevOps, as a philosophy, is about breaking down the wall between development and operations. It promotes shared responsibility, automation, and fast feedback loops. DevOps teams typically own CI/CD pipelines, production infrastructure, monitoring, and incident response.",[15,15160,15161],{},"Platform engineering takes the DevOps philosophy further and applies product-thinking to it. The platform team asks: \"What is the product that makes our developers most effective?\" and builds it with the same rigor they would apply to a customer-facing product.",[15,15163,15164],{},"The key differences:",[15,15166,15167,15170],{},[32,15168,15169],{},"Customer orientation."," DevOps teams manage infrastructure for the organization. Platform teams build products for internal developer customers, with user research, roadmaps, adoption metrics, and product reviews.",[15,15172,15173,15176],{},[32,15174,15175],{},"Self-service vs ticket-driven."," Traditional DevOps often involves developers requesting resources from ops. Platform engineering provides self-service capabilities that eliminate the ticket queue.",[15,15178,15179,15182],{},[32,15180,15181],{},"Cognitive load reduction."," Platform engineering explicitly aims to reduce the cognitive load on application developers. The measure of success isn't infrastructure uptime — it's how much complexity developers don't have to think about.",[15,15184,15185,15188],{},[32,15186,15187],{},"Team topology alignment."," Platform engineering emerged from the Team Topologies framework as a response to the scaling limitations of DevOps. As organizations grow, a DevOps team becomes a bottleneck. A platform team builds the product that lets development teams operate autonomously.",[2326,15190],{},[22,15192,15194],{"id":15193},"when-does-an-organization-need-a-platform-team","When Does an Organization Need a Platform Team?",[15,15196,15197],{},"Platform engineering investment makes sense when:",[15,15199,15200,15203],{},[32,15201,15202],{},"Development teams are spending significant time on infrastructure concerns."," If your engineers are regularly blocked by environment issues, deployment complexity, or observability gaps — and these concerns are taking time away from product work — that's platform value waiting to be captured.",[15,15205,15206,15209],{},[32,15207,15208],{},"Onboarding new services is slow."," If creating a new microservice requires two weeks of configuration, templates, and pipeline setup, your platform debt is directly limiting your velocity.",[15,15211,15212,15215],{},[32,15213,15214],{},"You have inconsistency at scale."," Twenty teams deploying services twenty different ways creates compliance gaps, security inconsistencies, and operational nightmares. A platform creates consistency without requiring top-down mandates.",[15,15217,15218,15221],{},[32,15219,15220],{},"Your engineering organization is growing."," Platform investment scales. A well-built internal developer platform serves 50 engineers and 500 engineers with the same infrastructure. Without it, operational complexity grows linearly with headcount.",[15,15223,15224],{},"Smaller organizations (under ~30 engineers) typically don't need a dedicated platform team. The overhead of building and maintaining an IDP isn't justified. What they need is good DevOps practices, clear deployment standards, and the simplest possible infrastructure. Platform engineering is a scaling investment.",[2326,15226],{},[22,15228,15230],{"id":15229},"the-failure-modes","The Failure Modes",[15,15232,15233],{},"Platform teams fail for predictable reasons:",[15,15235,15236,15239],{},[32,15237,15238],{},"Building for themselves, not for developers."," The most technically elegant Kubernetes abstraction in the world doesn't matter if developers find it harder to use than the alternative.",[15,15241,15242,15245],{},[32,15243,15244],{},"No product discipline."," Building a platform without understanding the developer's actual pain points, gathering feedback, or measuring adoption is infrastructure work dressed up as platform work.",[15,15247,15248,15251],{},[32,15249,15250],{},"Forced adoption."," Mandating that developers use the platform rather than making it so good they want to. Mandated platforms get minimal adoption and maximum resentment.",[15,15253,15254,15257],{},[32,15255,15256],{},"Under-investment."," A platform team of two people for 200 engineers cannot produce a platform that 200 engineers will find valuable. Platform teams need staffing proportional to their customer base.",[2326,15259],{},[15,15261,15262],{},"The organizations getting the most value from platform engineering are treating it seriously as a product discipline — with user research, prioritized roadmaps, adoption metrics, and a genuine commitment to developer experience. That mindset, more than any specific tool or technology, is what makes it work.",[2326,15264],{},[15,15266,15267,15268],{},"If you're thinking about whether your organization needs a platform team and what it should own, ",[2332,15269,15271],{"href":2334,"rel":15270},[2336],"let's have that conversation.",[2326,15273],{},[22,15275,2343],{"id":2342},[2304,15277,15278,15284,15290,15294],{},[2307,15279,15280],{},[2332,15281,15283],{"href":15282},"/blog/developer-experience-improvements","Developer Experience: The Hidden Multiplier on Team Output",[2307,15285,15286],{},[2332,15287,15289],{"href":15288},"/blog/software-documentation-best-practices","Software Documentation That Engineers Actually Read",[2307,15291,15292],{},[2332,15293,4366],{"href":4365},[2307,15295,15296],{},[2332,15297,15299],{"href":15298},"/blog/cqrs-event-sourcing-explained","CQRS and Event Sourcing: A Practitioner's Honest Take",{"title":69,"searchDepth":126,"depth":126,"links":15301},[15302,15303,15304,15305,15306,15307,15308,15309],{"id":15044,"depth":110,"text":15045},{"id":15059,"depth":110,"text":15060},{"id":15074,"depth":110,"text":15075},{"id":15113,"depth":110,"text":15114},{"id":15154,"depth":110,"text":15155},{"id":15193,"depth":110,"text":15194},{"id":15229,"depth":110,"text":15230},{"id":2342,"depth":110,"text":2343},"Platform engineering is one of the fastest-growing disciplines in software — but it's frequently confused with DevOps. Here's what internal developer platforms actually are and why they matter.",[15312,15313,15314,15315,15316],"platform engineering","internal developer platform","platform engineering vs DevOps","developer experience platform","golden path",{},"/blog/platform-engineering-explained",{"title":15038,"description":15310},"blog/platform-engineering-explained",[15322,13398,9428,15323],"Platform Engineering","Internal Developer Platform","IjvOUt5C2LhNewHm2CKXSc6bqIyMp1cdOUQitATPEQ0",{"id":15326,"title":15327,"author":15328,"body":15329,"category":2385,"date":2386,"description":16366,"extension":2388,"featured":2389,"image":2390,"keywords":16367,"meta":16370,"navigation":129,"path":16371,"readTime":165,"seo":16372,"stem":16373,"tags":16374,"__hash__":16378},"blog/blog/postgresql-full-text-search.md","PostgreSQL Full-Text Search: Better Than You Think, No Elasticsearch Required",{"name":9,"bio":10},{"type":12,"value":15330,"toc":16355},[15331,15334,15337,15341,15352,15391,15394,15414,15430,15434,15441,15485,15499,15505,15509,15568,15578,15582,15596,15644,15653,15657,15660,15725,15728,15748,15755,15764,15768,15775,15824,15827,15883,15887,15890,16185,16188,16273,16277,16280,16294,16297,16311,16314,16316,16322,16324,16326,16352],[15,15332,15333],{},"The instinct to reach for Elasticsearch or Typesense the moment search appears in a feature list is understandable but often wrong. PostgreSQL's full-text search is genuinely capable, and adding Elasticsearch to your architecture adds operational complexity — another service to deploy, monitor, keep in sync, and scale — that most applications do not need.",[15,15335,15336],{},"For many applications, PostgreSQL search is not just good enough. It is the right choice.",[22,15338,15340],{"id":15339},"understanding-tsvector-and-tsquery","Understanding tsvector and tsquery",[15,15342,15343,15344,15347,15348,15351],{},"PostgreSQL represents searchable documents as ",[59,15345,15346],{},"tsvector"," — a sorted list of lexemes (normalized word forms) with position information. The query language is ",[59,15349,15350],{},"tsquery"," — a boolean expression over lexemes.",[64,15353,15355],{"className":13008,"code":15354,"language":13010,"meta":69,"style":69},"-- Convert text to searchable tsvector\nSELECT to_tsvector('english', 'PostgreSQL is a powerful open-source database');\n-- Output: 'databas':8 'open-sourc':6 'postgreSql':1 'powerful':4\n\n-- Convert a search query to tsquery\nSELECT to_tsquery('english', 'postgresql & full-text');\n-- Output: 'postgresql' & 'full-text'\n",[59,15356,15357,15362,15367,15372,15376,15381,15386],{"__ignoreMap":69},[73,15358,15359],{"class":75,"line":76},[73,15360,15361],{},"-- Convert text to searchable tsvector\n",[73,15363,15364],{"class":75,"line":110},[73,15365,15366],{},"SELECT to_tsvector('english', 'PostgreSQL is a powerful open-source database');\n",[73,15368,15369],{"class":75,"line":126},[73,15370,15371],{},"-- Output: 'databas':8 'open-sourc':6 'postgreSql':1 'powerful':4\n",[73,15373,15374],{"class":75,"line":133},[73,15375,130],{"emptyLinePlaceholder":129},[73,15377,15378],{"class":75,"line":142},[73,15379,15380],{},"-- Convert a search query to tsquery\n",[73,15382,15383],{"class":75,"line":157},[73,15384,15385],{},"SELECT to_tsquery('english', 'postgresql & full-text');\n",[73,15387,15388],{"class":75,"line":165},[73,15389,15390],{},"-- Output: 'postgresql' & 'full-text'\n",[15,15392,15393],{},"The text processing pipeline:",[3920,15395,15396,15402,15408],{},[2307,15397,15398,15401],{},[32,15399,15400],{},"Parser:"," splits text into tokens (words, URLs, email addresses, etc.)",[2307,15403,15404,15407],{},[32,15405,15406],{},"Dictionary:"," normalizes tokens — removes stop words, applies stemming",[2307,15409,15410,15413],{},[32,15411,15412],{},"Result:"," a vector of normalized terms with position information",[15,15415,57,15416,15419,15420,710,15423,710,15426,15429],{},[59,15417,15418],{},"english"," configuration does English-specific processing. Other configurations handle other languages: ",[59,15421,15422],{},"french",[59,15424,15425],{},"german",[59,15427,15428],{},"spanish",", etc.",[22,15431,15433],{"id":15432},"setting-up-full-text-search","Setting Up Full-Text Search",[15,15435,15436,15437,15440],{},"Add a generated ",[59,15438,15439],{},"search_vector"," column to your table:",[64,15442,15444],{"className":13008,"code":15443,"language":13010,"meta":69,"style":69},"ALTER TABLE posts ADD COLUMN search_vector tsvector\n GENERATED ALWAYS AS (\n setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n setweight(to_tsvector('english', coalesce(content, '')), 'C')\n ) STORED;\n\nCREATE INDEX idx_posts_search ON posts USING GIN (search_vector);\n",[59,15445,15446,15451,15456,15461,15466,15471,15476,15480],{"__ignoreMap":69},[73,15447,15448],{"class":75,"line":76},[73,15449,15450],{},"ALTER TABLE posts ADD COLUMN search_vector tsvector\n",[73,15452,15453],{"class":75,"line":110},[73,15454,15455],{}," GENERATED ALWAYS AS (\n",[73,15457,15458],{"class":75,"line":126},[73,15459,15460],{}," setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n",[73,15462,15463],{"class":75,"line":133},[73,15464,15465],{}," setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n",[73,15467,15468],{"class":75,"line":142},[73,15469,15470],{}," setweight(to_tsvector('english', coalesce(content, '')), 'C')\n",[73,15472,15473],{"class":75,"line":157},[73,15474,15475],{}," ) STORED;\n",[73,15477,15478],{"class":75,"line":165},[73,15479,130],{"emptyLinePlaceholder":129},[73,15481,15482],{"class":75,"line":178},[73,15483,15484],{},"CREATE INDEX idx_posts_search ON posts USING GIN (search_vector);\n",[15,15486,57,15487,15490,15491,15494,15495,15498],{},[59,15488,15489],{},"setweight"," function assigns different weights to different fields. Weight ",[59,15492,15493],{},"A"," (most important) through ",[59,15496,15497],{},"D"," (least) affects ranking — title matches rank higher than body matches.",[15,15500,57,15501,15504],{},[59,15502,15503],{},"GENERATED ALWAYS AS ... STORED"," syntax creates a column that is automatically maintained by PostgreSQL. No triggers, no application code to keep it in sync.",[22,15506,15508],{"id":15507},"basic-search-queries","Basic Search Queries",[64,15510,15512],{"className":13008,"code":15511,"language":13010,"meta":69,"style":69},"-- Find posts matching a search query\nSELECT\n id,\n title,\n description,\n ts_rank(search_vector, query) AS rank\nFROM posts,\n to_tsquery('english', 'postgresql & indexing') query\nWHERE search_vector @@ query\nORDER BY rank DESC\nLIMIT 20;\n",[59,15513,15514,15519,15524,15529,15534,15539,15544,15549,15554,15559,15564],{"__ignoreMap":69},[73,15515,15516],{"class":75,"line":76},[73,15517,15518],{},"-- Find posts matching a search query\n",[73,15520,15521],{"class":75,"line":110},[73,15522,15523],{},"SELECT\n",[73,15525,15526],{"class":75,"line":126},[73,15527,15528],{}," id,\n",[73,15530,15531],{"class":75,"line":133},[73,15532,15533],{}," title,\n",[73,15535,15536],{"class":75,"line":142},[73,15537,15538],{}," description,\n",[73,15540,15541],{"class":75,"line":157},[73,15542,15543],{}," ts_rank(search_vector, query) AS rank\n",[73,15545,15546],{"class":75,"line":165},[73,15547,15548],{},"FROM posts,\n",[73,15550,15551],{"class":75,"line":178},[73,15552,15553],{}," to_tsquery('english', 'postgresql & indexing') query\n",[73,15555,15556],{"class":75,"line":191},[73,15557,15558],{},"WHERE search_vector @@ query\n",[73,15560,15561],{"class":75,"line":204},[73,15562,15563],{},"ORDER BY rank DESC\n",[73,15565,15566],{"class":75,"line":217},[73,15567,13076],{},[15,15569,57,15570,15573,15574,15577],{},[59,15571,15572],{},"@@"," operator tests whether the document matches the query. ",[59,15575,15576],{},"ts_rank"," computes a relevance score from 0 to 1 based on how frequently matching terms appear.",[22,15579,15581],{"id":15580},"handling-user-input-safely","Handling User Input Safely",[15,15583,15584,15585,15588,15589,11299,15592,15595],{},"User search queries need sanitization. Raw user input can contain characters that break ",[59,15586,15587],{},"to_tsquery",". Use ",[59,15590,15591],{},"plainto_tsquery",[59,15593,15594],{},"websearch_to_tsquery"," instead:",[64,15597,15599],{"className":13008,"code":15598,"language":13010,"meta":69,"style":69},"-- plainto_tsquery: treats the whole string as AND of words\nSELECT * FROM posts\nWHERE search_vector @@ plainto_tsquery('english', 'postgresql full text search');\n-- Equivalent to: postgresql & full & text & search\n\n-- websearch_to_tsquery: supports quoted phrases and - exclusions (like Google)\nSELECT * FROM posts\nWHERE search_vector @@ websearch_to_tsquery('english', '\"full text search\" -elasticsearch');\n-- Equivalent to: 'full text search' phrase AND NOT elasticsearch\n",[59,15600,15601,15606,15611,15616,15621,15625,15630,15634,15639],{"__ignoreMap":69},[73,15602,15603],{"class":75,"line":76},[73,15604,15605],{},"-- plainto_tsquery: treats the whole string as AND of words\n",[73,15607,15608],{"class":75,"line":110},[73,15609,15610],{},"SELECT * FROM posts\n",[73,15612,15613],{"class":75,"line":126},[73,15614,15615],{},"WHERE search_vector @@ plainto_tsquery('english', 'postgresql full text search');\n",[73,15617,15618],{"class":75,"line":133},[73,15619,15620],{},"-- Equivalent to: postgresql & full & text & search\n",[73,15622,15623],{"class":75,"line":142},[73,15624,130],{"emptyLinePlaceholder":129},[73,15626,15627],{"class":75,"line":157},[73,15628,15629],{},"-- websearch_to_tsquery: supports quoted phrases and - exclusions (like Google)\n",[73,15631,15632],{"class":75,"line":165},[73,15633,15610],{},[73,15635,15636],{"class":75,"line":178},[73,15637,15638],{},"WHERE search_vector @@ websearch_to_tsquery('english', '\"full text search\" -elasticsearch');\n",[73,15640,15641],{"class":75,"line":191},[73,15642,15643],{},"-- Equivalent to: 'full text search' phrase AND NOT elasticsearch\n",[15,15645,15646,15648,15649,15652],{},[59,15647,15594],{}," is my default for user-facing search. Users are familiar with quoted phrases and ",[59,15650,15651],{},"-exclusions"," from search engines, and this function handles malformed input gracefully.",[22,15654,15656],{"id":15655},"search-result-highlighting","Search Result Highlighting",[15,15658,15659],{},"PostgreSQL can generate highlighted excerpts showing where the search terms appear in your text:",[64,15661,15663],{"className":13008,"code":15662,"language":13010,"meta":69,"style":69},"SELECT\n id,\n title,\n ts_headline(\n 'english',\n content,\n query,\n 'MaxWords=50, MinWords=20, MaxFragments=3, HighlightAll=false'\n ) AS excerpt\nFROM posts,\n websearch_to_tsquery('english', 'postgresql indexing') query\nWHERE search_vector @@ query\nORDER BY ts_rank(search_vector, query) DESC;\n",[59,15664,15665,15669,15673,15677,15682,15687,15692,15697,15702,15707,15711,15716,15720],{"__ignoreMap":69},[73,15666,15667],{"class":75,"line":76},[73,15668,15523],{},[73,15670,15671],{"class":75,"line":110},[73,15672,15528],{},[73,15674,15675],{"class":75,"line":126},[73,15676,15533],{},[73,15678,15679],{"class":75,"line":133},[73,15680,15681],{}," ts_headline(\n",[73,15683,15684],{"class":75,"line":142},[73,15685,15686],{}," 'english',\n",[73,15688,15689],{"class":75,"line":157},[73,15690,15691],{}," content,\n",[73,15693,15694],{"class":75,"line":165},[73,15695,15696],{}," query,\n",[73,15698,15699],{"class":75,"line":178},[73,15700,15701],{}," 'MaxWords=50, MinWords=20, MaxFragments=3, HighlightAll=false'\n",[73,15703,15704],{"class":75,"line":191},[73,15705,15706],{}," ) AS excerpt\n",[73,15708,15709],{"class":75,"line":204},[73,15710,15548],{},[73,15712,15713],{"class":75,"line":217},[73,15714,15715],{}," websearch_to_tsquery('english', 'postgresql indexing') query\n",[73,15717,15718],{"class":75,"line":230},[73,15719,15558],{},[73,15721,15722],{"class":75,"line":243},[73,15723,15724],{},"ORDER BY ts_rank(search_vector, query) DESC;\n",[15,15726,15727],{},"The options:",[2304,15729,15730,15736,15742],{},[2307,15731,15732,15735],{},[59,15733,15734],{},"MaxWords/MinWords",": excerpt length",[2307,15737,15738,15741],{},[59,15739,15740],{},"MaxFragments",": how many separate excerpt fragments to include",[2307,15743,15744,15747],{},[59,15745,15746],{},"HighlightAll",": highlight all occurrences (slower) or just the most relevant",[15,15749,15750,15751,15754],{},"The default HTML output wraps matches in ",[59,15752,15753],{},"\u003Cb>"," tags. Configure custom tags:",[64,15756,15758],{"className":13008,"code":15757,"language":13010,"meta":69,"style":69},"'StartSel=\u003Cmark>, StopSel=\u003C/mark>, MaxWords=50, MinWords=20'\n",[59,15759,15760],{"__ignoreMap":69},[73,15761,15762],{"class":75,"line":76},[73,15763,15757],{},[22,15765,15767],{"id":15766},"fuzzy-matching-with-pg_trgm","Fuzzy Matching With pg_trgm",[15,15769,15770,15771,15774],{},"Full-text search does not handle typos. For fuzzy matching — finding \"PostgreSQl\" when searching for \"PostgreSQL\" — use the ",[59,15772,15773],{},"pg_trgm"," extension:",[64,15776,15778],{"className":13008,"code":15777,"language":13010,"meta":69,"style":69},"CREATE EXTENSION IF NOT EXISTS pg_trgm;\nCREATE INDEX idx_posts_title_trgm ON posts USING GIN (title gin_trgm_ops);\n\n-- Find posts with titles similar to the query\nSELECT title, similarity(title, 'PostgresQL indexing') AS sim\nFROM posts\nWHERE title % 'PostgresQL indexing' -- % is the similarity threshold operator\nORDER BY sim DESC\nLIMIT 10;\n",[59,15779,15780,15785,15790,15794,15799,15804,15809,15814,15819],{"__ignoreMap":69},[73,15781,15782],{"class":75,"line":76},[73,15783,15784],{},"CREATE EXTENSION IF NOT EXISTS pg_trgm;\n",[73,15786,15787],{"class":75,"line":110},[73,15788,15789],{},"CREATE INDEX idx_posts_title_trgm ON posts USING GIN (title gin_trgm_ops);\n",[73,15791,15792],{"class":75,"line":126},[73,15793,130],{"emptyLinePlaceholder":129},[73,15795,15796],{"class":75,"line":133},[73,15797,15798],{},"-- Find posts with titles similar to the query\n",[73,15800,15801],{"class":75,"line":142},[73,15802,15803],{},"SELECT title, similarity(title, 'PostgresQL indexing') AS sim\n",[73,15805,15806],{"class":75,"line":157},[73,15807,15808],{},"FROM posts\n",[73,15810,15811],{"class":75,"line":165},[73,15812,15813],{},"WHERE title % 'PostgresQL indexing' -- % is the similarity threshold operator\n",[73,15815,15816],{"class":75,"line":178},[73,15817,15818],{},"ORDER BY sim DESC\n",[73,15820,15821],{"class":75,"line":191},[73,15822,15823],{},"LIMIT 10;\n",[15,15825,15826],{},"Combine full-text search for relevant results with trigram similarity for typo tolerance:",[64,15828,15830],{"className":13008,"code":15829,"language":13010,"meta":69,"style":69},"SELECT\n id,\n title,\n ts_rank(search_vector, to_tsquery('english', 'postgresql')) AS text_rank,\n similarity(title, 'postgresql') AS fuzzy_rank,\n (ts_rank(search_vector, to_tsquery('english', 'postgresql')) * 0.7 +\n similarity(title, 'postgresql') * 0.3) AS combined_rank\nFROM posts\nWHERE search_vector @@ to_tsquery('english', 'postgresql')\n OR title % 'postgresql'\nORDER BY combined_rank DESC;\n",[59,15831,15832,15836,15840,15844,15849,15854,15859,15864,15868,15873,15878],{"__ignoreMap":69},[73,15833,15834],{"class":75,"line":76},[73,15835,15523],{},[73,15837,15838],{"class":75,"line":110},[73,15839,15528],{},[73,15841,15842],{"class":75,"line":126},[73,15843,15533],{},[73,15845,15846],{"class":75,"line":133},[73,15847,15848],{}," ts_rank(search_vector, to_tsquery('english', 'postgresql')) AS text_rank,\n",[73,15850,15851],{"class":75,"line":142},[73,15852,15853],{}," similarity(title, 'postgresql') AS fuzzy_rank,\n",[73,15855,15856],{"class":75,"line":157},[73,15857,15858],{}," (ts_rank(search_vector, to_tsquery('english', 'postgresql')) * 0.7 +\n",[73,15860,15861],{"class":75,"line":165},[73,15862,15863],{}," similarity(title, 'postgresql') * 0.3) AS combined_rank\n",[73,15865,15866],{"class":75,"line":178},[73,15867,15808],{},[73,15869,15870],{"class":75,"line":191},[73,15871,15872],{},"WHERE search_vector @@ to_tsquery('english', 'postgresql')\n",[73,15874,15875],{"class":75,"line":204},[73,15876,15877],{}," OR title % 'postgresql'\n",[73,15879,15880],{"class":75,"line":217},[73,15881,15882],{},"ORDER BY combined_rank DESC;\n",[22,15884,15886],{"id":15885},"adding-search-to-your-orm","Adding Search to Your ORM",[15,15888,15889],{},"With Prisma, use raw queries for full-text search operations that are not supported in the Prisma client API:",[64,15891,15893],{"className":97,"code":15892,"language":99,"meta":69,"style":69},"async function searchPosts(query: string, page = 1, limit = 20) {\n const offset = (page - 1) * limit\n\n const results = await prisma.$queryRaw\u003CSearchResult[]>`\n SELECT\n id,\n title,\n description,\n ts_headline(\n 'english',\n content,\n websearch_to_tsquery('english', ${query}),\n 'MaxWords=50, MinWords=15, MaxFragments=2'\n ) AS excerpt,\n ts_rank(search_vector, websearch_to_tsquery('english', ${query})) AS rank\n FROM posts\n WHERE search_vector @@ websearch_to_tsquery('english', ${query})\n ORDER BY rank DESC\n LIMIT ${limit}\n OFFSET ${offset}\n `\n\n const [{ count }] = await prisma.$queryRaw\u003C[{ count: bigint }]>`\n SELECT COUNT(*) as count\n FROM posts\n WHERE search_vector @@ websearch_to_tsquery('english', ${query})\n `\n\n return {\n results,\n total: Number(count),\n }\n}\n",[59,15894,15895,15933,15957,15961,15989,15994,15998,16002,16006,16010,16014,16018,16028,16033,16038,16048,16053,16062,16067,16076,16086,16091,16095,16130,16135,16139,16147,16151,16155,16161,16166,16177,16181],{"__ignoreMap":69},[73,15896,15897,15899,15901,15904,15906,15908,15910,15912,15914,15916,15918,15921,15923,15926,15928,15931],{"class":75,"line":76},[73,15898,1996],{"class":631},[73,15900,939],{"class":631},[73,15902,15903],{"class":79}," searchPosts",[73,15905,977],{"class":116},[73,15907,9976],{"class":624},[73,15909,2425],{"class":631},[73,15911,9769],{"class":87},[73,15913,710],{"class":116},[73,15915,7139],{"class":624},[73,15917,956],{"class":631},[73,15919,15920],{"class":87}," 1",[73,15922,710],{"class":116},[73,15924,15925],{"class":624},"limit",[73,15927,956],{"class":631},[73,15929,15930],{"class":87}," 20",[73,15932,1349],{"class":116},[73,15934,15935,15937,15940,15942,15945,15948,15950,15952,15954],{"class":75,"line":110},[73,15936,950],{"class":631},[73,15938,15939],{"class":87}," offset",[73,15941,956],{"class":631},[73,15943,15944],{"class":116}," (page ",[73,15946,15947],{"class":631},"-",[73,15949,15920],{"class":87},[73,15951,1715],{"class":116},[73,15953,4805],{"class":631},[73,15955,15956],{"class":116}," limit\n",[73,15958,15959],{"class":75,"line":126},[73,15960,130],{"emptyLinePlaceholder":129},[73,15962,15963,15965,15968,15970,15972,15975,15978,15980,15983,15986],{"class":75,"line":133},[73,15964,950],{"class":631},[73,15966,15967],{"class":87}," results",[73,15969,956],{"class":631},[73,15971,1401],{"class":631},[73,15973,15974],{"class":116}," prisma.",[73,15976,15977],{"class":79},"$queryRaw",[73,15979,1115],{"class":116},[73,15981,15982],{"class":79},"SearchResult",[73,15984,15985],{"class":116},"[]>",[73,15987,15988],{"class":83},"`\n",[73,15990,15991],{"class":75,"line":142},[73,15992,15993],{"class":83}," SELECT\n",[73,15995,15996],{"class":75,"line":157},[73,15997,15528],{"class":83},[73,15999,16000],{"class":75,"line":165},[73,16001,15533],{"class":83},[73,16003,16004],{"class":75,"line":178},[73,16005,15538],{"class":83},[73,16007,16008],{"class":75,"line":191},[73,16009,15681],{"class":83},[73,16011,16012],{"class":75,"line":204},[73,16013,15686],{"class":83},[73,16015,16016],{"class":75,"line":217},[73,16017,15691],{"class":83},[73,16019,16020,16023,16025],{"class":75,"line":230},[73,16021,16022],{"class":83}," websearch_to_tsquery('english', ${",[73,16024,9976],{"class":116},[73,16026,16027],{"class":83},"}),\n",[73,16029,16030],{"class":75,"line":243},[73,16031,16032],{"class":83}," 'MaxWords=50, MinWords=15, MaxFragments=2'\n",[73,16034,16035],{"class":75,"line":256},[73,16036,16037],{"class":83}," ) AS excerpt,\n",[73,16039,16040,16043,16045],{"class":75,"line":265},[73,16041,16042],{"class":83}," ts_rank(search_vector, websearch_to_tsquery('english', ${",[73,16044,9976],{"class":116},[73,16046,16047],{"class":83},"})) AS rank\n",[73,16049,16050],{"class":75,"line":271},[73,16051,16052],{"class":83}," FROM posts\n",[73,16054,16055,16058,16060],{"class":75,"line":282},[73,16056,16057],{"class":83}," WHERE search_vector @@ websearch_to_tsquery('english', ${",[73,16059,9976],{"class":116},[73,16061,1379],{"class":83},[73,16063,16064],{"class":75,"line":293},[73,16065,16066],{"class":83}," ORDER BY rank DESC\n",[73,16068,16069,16072,16074],{"class":75,"line":304},[73,16070,16071],{"class":83}," LIMIT ${",[73,16073,15925],{"class":116},[73,16075,1098],{"class":83},[73,16077,16078,16081,16084],{"class":75,"line":310},[73,16079,16080],{"class":83}," OFFSET ${",[73,16082,16083],{"class":116},"offset",[73,16085,1098],{"class":83},[73,16087,16088],{"class":75,"line":315},[73,16089,16090],{"class":83}," `\n",[73,16092,16093],{"class":75,"line":325},[73,16094,130],{"emptyLinePlaceholder":129},[73,16096,16097,16099,16102,16104,16107,16109,16111,16113,16115,16118,16120,16122,16125,16128],{"class":75,"line":335},[73,16098,950],{"class":631},[73,16100,16101],{"class":116}," [{ ",[73,16103,4940],{"class":87},[73,16105,16106],{"class":116}," }] ",[73,16108,991],{"class":631},[73,16110,1401],{"class":631},[73,16112,15974],{"class":116},[73,16114,15977],{"class":79},[73,16116,16117],{"class":116},"\u003C[{ ",[73,16119,4940],{"class":624},[73,16121,2425],{"class":631},[73,16123,16124],{"class":87}," bigint",[73,16126,16127],{"class":116}," }]>",[73,16129,15988],{"class":83},[73,16131,16132],{"class":75,"line":344},[73,16133,16134],{"class":83}," SELECT COUNT(*) as count\n",[73,16136,16137],{"class":75,"line":349},[73,16138,16052],{"class":83},[73,16140,16141,16143,16145],{"class":75,"line":354},[73,16142,16057],{"class":83},[73,16144,9976],{"class":116},[73,16146,1379],{"class":83},[73,16148,16149],{"class":75,"line":363},[73,16150,16090],{"class":83},[73,16152,16153],{"class":75,"line":372},[73,16154,130],{"emptyLinePlaceholder":129},[73,16156,16157,16159],{"class":75,"line":381},[73,16158,1084],{"class":631},[73,16160,268],{"class":116},[73,16162,16163],{"class":75,"line":392},[73,16164,16165],{"class":116}," results,\n",[73,16167,16168,16171,16174],{"class":75,"line":397},[73,16169,16170],{"class":116}," total: ",[73,16172,16173],{"class":79},"Number",[73,16175,16176],{"class":116},"(count),\n",[73,16178,16179],{"class":75,"line":403},[73,16180,1889],{"class":116},[73,16182,16183],{"class":75,"line":408},[73,16184,1098],{"class":116},[15,16186,16187],{},"With Drizzle:",[64,16189,16191],{"className":97,"code":16190,"language":99,"meta":69,"style":69},"import { sql } from 'drizzle-orm'\n\nConst results = await db.execute(sql`\n SELECT id, title,\n ts_rank(search_vector, websearch_to_tsquery('english', ${query})) AS rank\n FROM posts\n WHERE search_vector @@ websearch_to_tsquery('english', ${query})\n ORDER BY rank DESC\n LIMIT ${limit}\n`)\n",[59,16192,16193,16205,16209,16229,16234,16242,16246,16254,16258,16266],{"__ignoreMap":69},[73,16194,16195,16197,16200,16202],{"class":75,"line":76},[73,16196,2143],{"class":631},[73,16198,16199],{"class":116}," { sql } ",[73,16201,2149],{"class":631},[73,16203,16204],{"class":83}," 'drizzle-orm'\n",[73,16206,16207],{"class":75,"line":110},[73,16208,130],{"emptyLinePlaceholder":129},[73,16210,16211,16214,16216,16218,16220,16223,16225,16227],{"class":75,"line":126},[73,16212,16213],{"class":116},"Const results ",[73,16215,991],{"class":631},[73,16217,1401],{"class":631},[73,16219,12053],{"class":116},[73,16221,16222],{"class":79},"execute",[73,16224,977],{"class":116},[73,16226,13010],{"class":79},[73,16228,15988],{"class":83},[73,16230,16231],{"class":75,"line":133},[73,16232,16233],{"class":83}," SELECT id, title,\n",[73,16235,16236,16238,16240],{"class":75,"line":142},[73,16237,16042],{"class":83},[73,16239,9976],{"class":116},[73,16241,16047],{"class":83},[73,16243,16244],{"class":75,"line":157},[73,16245,16052],{"class":83},[73,16247,16248,16250,16252],{"class":75,"line":165},[73,16249,16057],{"class":83},[73,16251,9976],{"class":116},[73,16253,1379],{"class":83},[73,16255,16256],{"class":75,"line":178},[73,16257,16066],{"class":83},[73,16259,16260,16262,16264],{"class":75,"line":191},[73,16261,16071],{"class":83},[73,16263,15925],{"class":116},[73,16265,1098],{"class":83},[73,16267,16268,16271],{"class":75,"line":204},[73,16269,16270],{"class":83},"`",[73,16272,1370],{"class":116},[22,16274,16276],{"id":16275},"when-to-choose-elasticsearch-over-postgresql","When to Choose Elasticsearch Over PostgreSQL",[15,16278,16279],{},"PostgreSQL search is excellent for:",[2304,16281,16282,16285,16288,16291],{},[2307,16283,16284],{},"Applications with under 10-20 million searchable documents",[2307,16286,16287],{},"Search over structured data with filtering by other columns",[2307,16289,16290],{},"Applications where simplicity and reduced operational overhead matter",[2307,16292,16293],{},"Budget-conscious deployments where another service has real cost",[15,16295,16296],{},"Consider Elasticsearch or Typesense when:",[2304,16298,16299,16302,16305,16308],{},[2307,16300,16301],{},"You need sub-50ms search over 100+ million documents",[2307,16303,16304],{},"You need sophisticated relevance tuning (BM25, custom scoring)",[2307,16306,16307],{},"You need faceted search with real-time aggregations at scale",[2307,16309,16310],{},"You have a dedicated search use case where Elasticsearch's specialized features justify the operational cost",[15,16312,16313],{},"Most SaaS applications I have worked on never reach the scale where PostgreSQL's search limitations become real problems. Start with PostgreSQL, instrument your query performance, and migrate if the data shows you need to.",[2326,16315],{},[15,16317,16318,16319,2274],{},"Adding search to your application and unsure whether to reach for PostgreSQL or a dedicated search service? I can help you make the right call. Book a call: ",[2332,16320,2337],{"href":2334,"rel":16321},[2336],[2326,16323],{},[22,16325,2343],{"id":2342},[2304,16327,16328,16334,16340,16346],{},[2307,16329,16330],{},[2332,16331,16333],{"href":16332},"/blog/postgresql-row-level-security","PostgreSQL Row-Level Security: Data Isolation at the Database Layer",[2307,16335,16336],{},[2332,16337,16339],{"href":16338},"/blog/database-backup-strategies","Database Backup Strategies for Production: The Ones That Actually Work",[2307,16341,16342],{},[2332,16343,16345],{"href":16344},"/blog/database-connection-pooling","Database Connection Pooling: Why It Matters and How to Configure It",[2307,16347,16348],{},[2332,16349,16351],{"href":16350},"/blog/database-indexing-strategies","Database Indexing Strategies That Actually Make Queries Fast",[2371,16353,16354],{},"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 pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":69,"searchDepth":126,"depth":126,"links":16356},[16357,16358,16359,16360,16361,16362,16363,16364,16365],{"id":15339,"depth":110,"text":15340},{"id":15432,"depth":110,"text":15433},{"id":15507,"depth":110,"text":15508},{"id":15580,"depth":110,"text":15581},{"id":15655,"depth":110,"text":15656},{"id":15766,"depth":110,"text":15767},{"id":15885,"depth":110,"text":15886},{"id":16275,"depth":110,"text":16276},{"id":2342,"depth":110,"text":2343},"A complete guide to PostgreSQL full-text search — tsvector, tsquery, GIN indexes, ranking, highlighting, and when PostgreSQL search beats the dedicated alternatives.",[16368,16369],"PostgreSQL full text search","PostgreSQL search",{},"/blog/postgresql-full-text-search",{"title":15327,"description":16366},"blog/postgresql-full-text-search",[16375,16376,16377],"PostgreSQL","Search","Database","pabnfFR9_qxG7NwIZIptEdOJoOkM6MnP-Kur-lCLKUw",{"id":16380,"title":16381,"author":16382,"body":16383,"category":2385,"date":2386,"description":17053,"extension":2388,"featured":2389,"image":2390,"keywords":17054,"meta":17057,"navigation":129,"path":17058,"readTime":165,"seo":17059,"stem":17060,"tags":17061,"__hash__":17063},"blog/blog/postgresql-json-guide.md","PostgreSQL JSON: When to Use JSONB and When to Normalize",{"name":9,"bio":10},{"type":12,"value":16384,"toc":17042},[16385,16388,16391,16395,16398,16403,16409,16412,16416,16422,16536,16542,16571,16577,16583,16587,16677,16683,16711,16715,16718,16736,16745,16748,16756,16765,16771,16777,16792,16795,16799,16809,16815,16825,16838,16842,16845,16859,16862,16876,16880,16883,16935,16938,16942,16945,17003,17006,17009,17011,17017,17019,17021,17039],[15,16386,16387],{},"PostgreSQL's JSONB support is genuinely excellent, and it gets used in two ways: appropriately as a tool for genuinely flexible data, and inappropriately as a way to avoid schema design. The consequences of misuse are slow queries, complex maintenance, and lost query planner intelligence.",[15,16389,16390],{},"Here is how to use JSONB well and know when not to use it at all.",[22,16392,16394],{"id":16393},"json-vs-jsonb","JSON vs JSONB",[15,16396,16397],{},"PostgreSQL has two JSON types:",[15,16399,16400,16402],{},[32,16401,2271],{}," stores the raw JSON string, preserving whitespace and key order. Parsing happens at query time. It is essentially TEXT with JSON validation.",[15,16404,16405,16408],{},[32,16406,16407],{},"JSONB"," stores JSON in a decomposed binary format. It is parsed at insert time, key order is not preserved, duplicate keys are discarded, and it supports indexing and operators. JSONB is almost always the right choice.",[15,16410,16411],{},"The only case to reach for plain JSON is when you specifically need to preserve key ordering or duplicate keys, which is rare.",[22,16413,16415],{"id":16414},"when-jsonb-makes-sense","When JSONB Makes Sense",[15,16417,16418,16421],{},[32,16419,16420],{},"Schema-less or highly variable attributes."," Product attributes in an e-commerce system are a classic example. A shirt has size, color, and material. An electronics item has voltage, frequency, and certification. Storing every possible attribute as a column would require hundreds of nullable columns. JSONB handles this elegantly:",[64,16423,16425],{"className":13008,"code":16424,"language":13010,"meta":69,"style":69},"CREATE TABLE products (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name TEXT NOT NULL,\n category TEXT NOT NULL,\n price NUMERIC(10, 2) NOT NULL,\n attributes JSONB NOT NULL DEFAULT '{}'\n);\n\n-- Electronics product\nINSERT INTO products (name, category, price, attributes) VALUES (\n 'Laptop Pro X',\n 'electronics',\n 1299.99,\n '{\"voltage\": \"100-240V\", \"wattage\": 65, \"ports\": [\"USB-C\", \"HDMI\", \"USB-A\"], \"certifications\": [\"CE\", \"FCC\"]}'\n);\n\n-- Clothing product\nINSERT INTO products (name, category, price, attributes) VALUES (\n 'Cotton T-Shirt',\n 'apparel',\n 29.99,\n '{\"sizes\": [\"S\", \"M\", \"L\", \"XL\"], \"material\": \"100% cotton\", \"care\": \"Machine wash cold\"}'\n);\n",[59,16426,16427,16432,16437,16442,16447,16452,16457,16461,16465,16470,16475,16480,16485,16490,16495,16499,16503,16508,16512,16517,16522,16527,16532],{"__ignoreMap":69},[73,16428,16429],{"class":75,"line":76},[73,16430,16431],{},"CREATE TABLE products (\n",[73,16433,16434],{"class":75,"line":110},[73,16435,16436],{}," id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n",[73,16438,16439],{"class":75,"line":126},[73,16440,16441],{}," name TEXT NOT NULL,\n",[73,16443,16444],{"class":75,"line":133},[73,16445,16446],{}," category TEXT NOT NULL,\n",[73,16448,16449],{"class":75,"line":142},[73,16450,16451],{}," price NUMERIC(10, 2) NOT NULL,\n",[73,16453,16454],{"class":75,"line":157},[73,16455,16456],{}," attributes JSONB NOT NULL DEFAULT '{}'\n",[73,16458,16459],{"class":75,"line":165},[73,16460,11937],{},[73,16462,16463],{"class":75,"line":178},[73,16464,130],{"emptyLinePlaceholder":129},[73,16466,16467],{"class":75,"line":191},[73,16468,16469],{},"-- Electronics product\n",[73,16471,16472],{"class":75,"line":204},[73,16473,16474],{},"INSERT INTO products (name, category, price, attributes) VALUES (\n",[73,16476,16477],{"class":75,"line":217},[73,16478,16479],{}," 'Laptop Pro X',\n",[73,16481,16482],{"class":75,"line":230},[73,16483,16484],{}," 'electronics',\n",[73,16486,16487],{"class":75,"line":243},[73,16488,16489],{}," 1299.99,\n",[73,16491,16492],{"class":75,"line":256},[73,16493,16494],{}," '{\"voltage\": \"100-240V\", \"wattage\": 65, \"ports\": [\"USB-C\", \"HDMI\", \"USB-A\"], \"certifications\": [\"CE\", \"FCC\"]}'\n",[73,16496,16497],{"class":75,"line":265},[73,16498,11937],{},[73,16500,16501],{"class":75,"line":271},[73,16502,130],{"emptyLinePlaceholder":129},[73,16504,16505],{"class":75,"line":282},[73,16506,16507],{},"-- Clothing product\n",[73,16509,16510],{"class":75,"line":293},[73,16511,16474],{},[73,16513,16514],{"class":75,"line":304},[73,16515,16516],{}," 'Cotton T-Shirt',\n",[73,16518,16519],{"class":75,"line":310},[73,16520,16521],{}," 'apparel',\n",[73,16523,16524],{"class":75,"line":315},[73,16525,16526],{}," 29.99,\n",[73,16528,16529],{"class":75,"line":325},[73,16530,16531],{}," '{\"sizes\": [\"S\", \"M\", \"L\", \"XL\"], \"material\": \"100% cotton\", \"care\": \"Machine wash cold\"}'\n",[73,16533,16534],{"class":75,"line":335},[73,16535,11937],{},[15,16537,16538,16541],{},[32,16539,16540],{},"Configuration and settings."," Application configuration that varies per user or per tenant, especially when the schema of the configuration is expected to evolve:",[64,16543,16545],{"className":13008,"code":16544,"language":13010,"meta":69,"style":69},"CREATE TABLE user_settings (\n user_id UUID REFERENCES users(id),\n settings JSONB NOT NULL DEFAULT '{}',\n PRIMARY KEY (user_id)\n);\n",[59,16546,16547,16552,16557,16562,16567],{"__ignoreMap":69},[73,16548,16549],{"class":75,"line":76},[73,16550,16551],{},"CREATE TABLE user_settings (\n",[73,16553,16554],{"class":75,"line":110},[73,16555,16556],{}," user_id UUID REFERENCES users(id),\n",[73,16558,16559],{"class":75,"line":126},[73,16560,16561],{}," settings JSONB NOT NULL DEFAULT '{}',\n",[73,16563,16564],{"class":75,"line":133},[73,16565,16566],{}," PRIMARY KEY (user_id)\n",[73,16568,16569],{"class":75,"line":142},[73,16570,11937],{},[15,16572,16573,16576],{},[32,16574,16575],{},"Audit logs and event stores."," When you want to store the entire state of an object at a point in time, JSONB lets you store the serialized object without defining a schema for every possible past version.",[15,16578,16579,16582],{},[32,16580,16581],{},"Third-party API responses."," When you receive JSON from an external API and need to store it for later processing or reference, JSONB avoids the need to define a schema for every field.",[22,16584,16586],{"id":16585},"querying-jsonb","Querying JSONB",[64,16588,16590],{"className":13008,"code":16589,"language":13010,"meta":69,"style":69},"-- Access a top-level key\nSELECT attributes->>'voltage' FROM products;\n-- Returns the value as text\n\n-- Access a nested key\nSELECT attributes->'dimensions'->>'width' FROM products;\n\n-- Filter by a key value\nSELECT * FROM products WHERE attributes->>'material' = '100% cotton';\n\n-- Check if a key exists\nSELECT * FROM products WHERE attributes ? 'voltage';\n\n-- Check if all keys exist\nSELECT * FROM products WHERE attributes ?& ARRAY['voltage', 'wattage'];\n\n-- JSONB contains: left operand contains right operand\nSELECT * FROM products WHERE attributes @> '{\"certifications\": [\"CE\"]}';\n",[59,16591,16592,16597,16602,16607,16611,16616,16621,16625,16630,16635,16639,16644,16649,16653,16658,16663,16667,16672],{"__ignoreMap":69},[73,16593,16594],{"class":75,"line":76},[73,16595,16596],{},"-- Access a top-level key\n",[73,16598,16599],{"class":75,"line":110},[73,16600,16601],{},"SELECT attributes->>'voltage' FROM products;\n",[73,16603,16604],{"class":75,"line":126},[73,16605,16606],{},"-- Returns the value as text\n",[73,16608,16609],{"class":75,"line":133},[73,16610,130],{"emptyLinePlaceholder":129},[73,16612,16613],{"class":75,"line":142},[73,16614,16615],{},"-- Access a nested key\n",[73,16617,16618],{"class":75,"line":157},[73,16619,16620],{},"SELECT attributes->'dimensions'->>'width' FROM products;\n",[73,16622,16623],{"class":75,"line":165},[73,16624,130],{"emptyLinePlaceholder":129},[73,16626,16627],{"class":75,"line":178},[73,16628,16629],{},"-- Filter by a key value\n",[73,16631,16632],{"class":75,"line":191},[73,16633,16634],{},"SELECT * FROM products WHERE attributes->>'material' = '100% cotton';\n",[73,16636,16637],{"class":75,"line":204},[73,16638,130],{"emptyLinePlaceholder":129},[73,16640,16641],{"class":75,"line":217},[73,16642,16643],{},"-- Check if a key exists\n",[73,16645,16646],{"class":75,"line":230},[73,16647,16648],{},"SELECT * FROM products WHERE attributes ? 'voltage';\n",[73,16650,16651],{"class":75,"line":243},[73,16652,130],{"emptyLinePlaceholder":129},[73,16654,16655],{"class":75,"line":256},[73,16656,16657],{},"-- Check if all keys exist\n",[73,16659,16660],{"class":75,"line":265},[73,16661,16662],{},"SELECT * FROM products WHERE attributes ?& ARRAY['voltage', 'wattage'];\n",[73,16664,16665],{"class":75,"line":271},[73,16666,130],{"emptyLinePlaceholder":129},[73,16668,16669],{"class":75,"line":282},[73,16670,16671],{},"-- JSONB contains: left operand contains right operand\n",[73,16673,16674],{"class":75,"line":293},[73,16675,16676],{},"SELECT * FROM products WHERE attributes @> '{\"certifications\": [\"CE\"]}';\n",[15,16678,57,16679,16682],{},[59,16680,16681],{},"@>"," containment operator is particularly powerful — it checks whether one JSONB document contains another, supporting deep nesting:",[64,16684,16686],{"className":13008,"code":16685,"language":13010,"meta":69,"style":69},"-- Find all products with USB-C port\nSELECT * FROM products WHERE attributes @> '{\"ports\": [\"USB-C\"]}';\n\n-- Find all products with certifications including CE\nSELECT * FROM products WHERE attributes @> '{\"certifications\": [\"CE\"]}';\n",[59,16687,16688,16693,16698,16702,16707],{"__ignoreMap":69},[73,16689,16690],{"class":75,"line":76},[73,16691,16692],{},"-- Find all products with USB-C port\n",[73,16694,16695],{"class":75,"line":110},[73,16696,16697],{},"SELECT * FROM products WHERE attributes @> '{\"ports\": [\"USB-C\"]}';\n",[73,16699,16700],{"class":75,"line":126},[73,16701,130],{"emptyLinePlaceholder":129},[73,16703,16704],{"class":75,"line":133},[73,16705,16706],{},"-- Find all products with certifications including CE\n",[73,16708,16709],{"class":75,"line":142},[73,16710,16676],{},[22,16712,16714],{"id":16713},"indexing-jsonb","Indexing JSONB",[15,16716,16717],{},"Without indexes, JSONB queries require sequential scans. Three index types are available:",[15,16719,16720,16723,16724,710,16726,710,16729,710,16732,16735],{},[32,16721,16722],{},"GIN index on the entire column"," — supports ",[59,16725,16681],{},[59,16727,16728],{},"?",[59,16730,16731],{},"?&",[59,16733,16734],{},"?|"," operators:",[64,16737,16739],{"className":13008,"code":16738,"language":13010,"meta":69,"style":69},"CREATE INDEX idx_products_attributes ON products USING GIN(attributes);\n",[59,16740,16741],{"__ignoreMap":69},[73,16742,16743],{"class":75,"line":76},[73,16744,16738],{},[15,16746,16747],{},"This is the most flexible option but creates a large index. Use it when you query many different paths.",[15,16749,16750,16753,16754,2425],{},[32,16751,16752],{},"GIN index with jsonb_path_ops"," — more compact, only supports ",[59,16755,16681],{},[64,16757,16759],{"className":13008,"code":16758,"language":13010,"meta":69,"style":69},"CREATE INDEX idx_products_attributes_path ON products USING GIN(attributes jsonb_path_ops);\n",[59,16760,16761],{"__ignoreMap":69},[73,16762,16763],{"class":75,"line":76},[73,16764,16758],{},[15,16766,16767,16768,16770],{},"Smaller and faster for ",[59,16769,16681],{}," queries. Use this when containment queries are your primary pattern.",[15,16772,16773,16776],{},[32,16774,16775],{},"B-tree index on a specific path"," — for equality and range queries on a specific key:",[64,16778,16780],{"className":13008,"code":16779,"language":13010,"meta":69,"style":69},"CREATE INDEX idx_products_voltage ON products((attributes->>'voltage'));\nCREATE INDEX idx_products_wattage ON products(((attributes->>'wattage')::numeric));\n",[59,16781,16782,16787],{"__ignoreMap":69},[73,16783,16784],{"class":75,"line":76},[73,16785,16786],{},"CREATE INDEX idx_products_voltage ON products((attributes->>'voltage'));\n",[73,16788,16789],{"class":75,"line":110},[73,16790,16791],{},"CREATE INDEX idx_products_wattage ON products(((attributes->>'wattage')::numeric));\n",[15,16793,16794],{},"Expression indexes are the most efficient for querying a specific well-known path. Use them when you know which JSONB paths you will query frequently.",[22,16796,16798],{"id":16797},"when-not-to-use-jsonb","When NOT to Use JSONB",[15,16800,16801,16804,16805,16808],{},[32,16802,16803],{},"Columns you filter, join, or aggregate on regularly."," If you find yourself running ",[59,16806,16807],{},"WHERE attributes->>'status' = 'active'"," on millions of rows frequently, that should be a real column with an index. JSONB operators are more expensive than native column comparisons.",[15,16810,16811,16814],{},[32,16812,16813],{},"Data with a stable, well-known schema."," If the schema is known and stable, normalize it. Normalized data has better query planner support, cleaner constraints, and easier joins.",[15,16816,16817,16820,16821,16824],{},[32,16818,16819],{},"Foreign key targets."," You cannot define a foreign key that references a value inside a JSONB column. If you have ",[59,16822,16823],{},"attributes->>'category_id'"," that should reference the categories table, that belongs as a proper column.",[15,16826,16827,9128,16830,16833,16834,16837],{},[32,16828,16829],{},"Aggregations.",[59,16831,16832],{},"SUM((attributes->>'price')::numeric)"," is slower and syntactically ugly compared to ",[59,16835,16836],{},"SUM(price)",". If you need to aggregate on a field, it belongs as a column.",[22,16839,16841],{"id":16840},"the-decision-matrix","The Decision Matrix",[15,16843,16844],{},"Use JSONB when:",[2304,16846,16847,16850,16853,16856],{},[2307,16848,16849],{},"The structure varies significantly between rows",[2307,16851,16852],{},"The schema will evolve in ways you cannot predict",[2307,16854,16855],{},"You are storing third-party data with its own schema",[2307,16857,16858],{},"The data is read as a whole object more often than queried by individual fields",[15,16860,16861],{},"Use normalized columns when:",[2304,16863,16864,16867,16870,16873],{},[2307,16865,16866],{},"You filter, sort, join, or aggregate on the field",[2307,16868,16869],{},"You need foreign key constraints",[2307,16871,16872],{},"The field has a fixed schema across all rows",[2307,16874,16875],{},"Type safety and constraints are important (NOT NULL, CHECK constraints, etc.)",[22,16877,16879],{"id":16878},"combining-both-approaches","Combining Both Approaches",[15,16881,16882],{},"The best designs often combine normalized and JSONB fields:",[64,16884,16886],{"className":13008,"code":16885,"language":13010,"meta":69,"style":69},"CREATE TABLE products (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name TEXT NOT NULL, -- Always needed, indexed\n slug TEXT UNIQUE NOT NULL, -- Always needed, indexed\n category_id UUID REFERENCES categories(id), -- Join target\n price NUMERIC(10, 2) NOT NULL, -- Aggregated\n status TEXT NOT NULL DEFAULT 'draft', -- Filtered\n attributes JSONB NOT NULL DEFAULT '{}', -- Variable schema data\n created_at TIMESTAMP DEFAULT NOW()\n);\n",[59,16887,16888,16892,16896,16901,16906,16911,16916,16921,16926,16931],{"__ignoreMap":69},[73,16889,16890],{"class":75,"line":76},[73,16891,16431],{},[73,16893,16894],{"class":75,"line":110},[73,16895,16436],{},[73,16897,16898],{"class":75,"line":126},[73,16899,16900],{}," name TEXT NOT NULL, -- Always needed, indexed\n",[73,16902,16903],{"class":75,"line":133},[73,16904,16905],{}," slug TEXT UNIQUE NOT NULL, -- Always needed, indexed\n",[73,16907,16908],{"class":75,"line":142},[73,16909,16910],{}," category_id UUID REFERENCES categories(id), -- Join target\n",[73,16912,16913],{"class":75,"line":157},[73,16914,16915],{}," price NUMERIC(10, 2) NOT NULL, -- Aggregated\n",[73,16917,16918],{"class":75,"line":165},[73,16919,16920],{}," status TEXT NOT NULL DEFAULT 'draft', -- Filtered\n",[73,16922,16923],{"class":75,"line":178},[73,16924,16925],{}," attributes JSONB NOT NULL DEFAULT '{}', -- Variable schema data\n",[73,16927,16928],{"class":75,"line":191},[73,16929,16930],{}," created_at TIMESTAMP DEFAULT NOW()\n",[73,16932,16933],{"class":75,"line":204},[73,16934,11937],{},[15,16936,16937],{},"The core fields that you filter, join, and aggregate are proper columns. The variable per-product attributes live in JSONB. This gives you the best of both worlds.",[22,16939,16941],{"id":16940},"validation-with-check-constraints","Validation With CHECK Constraints",[15,16943,16944],{},"You can validate JSONB structure at the database level:",[64,16946,16948],{"className":13008,"code":16947,"language":13010,"meta":69,"style":69},"-- Ensure the attributes object has required keys\nALTER TABLE products ADD CONSTRAINT attributes_required_keys\n CHECK (\n attributes ? 'weight'\n AND attributes ? 'dimensions'\n )\n WHERE category = 'physical';\n\n-- Ensure a specific key has the right type\nALTER TABLE products ADD CONSTRAINT attributes_price_numeric\n CHECK (jsonb_typeof(attributes->'price') = 'number');\n",[59,16949,16950,16955,16960,16965,16970,16975,16979,16984,16988,16993,16998],{"__ignoreMap":69},[73,16951,16952],{"class":75,"line":76},[73,16953,16954],{},"-- Ensure the attributes object has required keys\n",[73,16956,16957],{"class":75,"line":110},[73,16958,16959],{},"ALTER TABLE products ADD CONSTRAINT attributes_required_keys\n",[73,16961,16962],{"class":75,"line":126},[73,16963,16964],{}," CHECK (\n",[73,16966,16967],{"class":75,"line":133},[73,16968,16969],{}," attributes ? 'weight'\n",[73,16971,16972],{"class":75,"line":142},[73,16973,16974],{}," AND attributes ? 'dimensions'\n",[73,16976,16977],{"class":75,"line":157},[73,16978,8836],{},[73,16980,16981],{"class":75,"line":165},[73,16982,16983],{}," WHERE category = 'physical';\n",[73,16985,16986],{"class":75,"line":178},[73,16987,130],{"emptyLinePlaceholder":129},[73,16989,16990],{"class":75,"line":191},[73,16991,16992],{},"-- Ensure a specific key has the right type\n",[73,16994,16995],{"class":75,"line":204},[73,16996,16997],{},"ALTER TABLE products ADD CONSTRAINT attributes_price_numeric\n",[73,16999,17000],{"class":75,"line":217},[73,17001,17002],{}," CHECK (jsonb_typeof(attributes->'price') = 'number');\n",[15,17004,17005],{},"These constraints enforce structure where it matters without requiring a fully normalized schema for every attribute.",[15,17007,17008],{},"JSONB in PostgreSQL is a genuinely powerful feature. Used well, it solves real schema flexibility problems without the operational complexity of a separate document store. Used carelessly, it turns your structured relational data into a bag of blobs that is hard to query and harder to maintain.",[2326,17010],{},[15,17012,17013,17014,2274],{},"Designing a data model that needs to balance relational structure with schema flexibility? I help teams make these trade-off decisions. Book a call: ",[2332,17015,2337],{"href":2334,"rel":17016},[2336],[2326,17018],{},[22,17020,2343],{"id":2342},[2304,17022,17023,17027,17031,17035],{},[2307,17024,17025],{},[2332,17026,15327],{"href":16371},[2307,17028,17029],{},[2332,17030,16333],{"href":16332},[2307,17032,17033],{},[2332,17034,16339],{"href":16338},[2307,17036,17037],{},[2332,17038,16345],{"href":16344},[2371,17040,17041],{},"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);}",{"title":69,"searchDepth":126,"depth":126,"links":17043},[17044,17045,17046,17047,17048,17049,17050,17051,17052],{"id":16393,"depth":110,"text":16394},{"id":16414,"depth":110,"text":16415},{"id":16585,"depth":110,"text":16586},{"id":16713,"depth":110,"text":16714},{"id":16797,"depth":110,"text":16798},{"id":16840,"depth":110,"text":16841},{"id":16878,"depth":110,"text":16879},{"id":16940,"depth":110,"text":16941},{"id":2342,"depth":110,"text":2343},"A practical guide to PostgreSQL JSONB — when it makes sense, querying and indexing JSONB data, the common pitfalls, and how to decide between JSONB and normalized tables.",[17055,17056],"PostgreSQL JSON","PostgreSQL JSONB",{},"/blog/postgresql-json-guide",{"title":16381,"description":17053},"blog/postgresql-json-guide",[16375,2271,17062],"Database Design","2zgptb710adB-nlNMXl0LyHQQInTccPPgpPpztpPm5s",{"id":17065,"title":16333,"author":17066,"body":17067,"category":2385,"date":2386,"description":18218,"extension":2388,"featured":2389,"image":2390,"keywords":18219,"meta":18222,"navigation":129,"path":16332,"readTime":165,"seo":18223,"stem":18224,"tags":18225,"__hash__":18226},"blog/blog/postgresql-row-level-security.md",{"name":9,"bio":10},{"type":12,"value":17068,"toc":18207},[17069,17076,17079,17082,17086,17089,17092,17096,17099,17133,17143,17146,17320,17329,17333,17336,17415,17424,17428,17431,17501,17504,17752,17756,17759,17793,17796,17831,17845,17849,17852,17855,18075,18078,18082,18085,18088,18103,18106,18135,18138,18142,18145,18151,18157,18163,18169,18172,18174,18180,18182,18184,18204],[15,17070,17071,17072,17075],{},"Most application developers handle data isolation in their application layer — every query includes a ",[59,17073,17074],{},"WHERE user_id = $currentUser"," clause, and the assumption is that developers will remember to add this filter every time, in every query, forever.",[15,17077,17078],{},"That assumption fails. Developers make mistakes. A missing WHERE clause is a data breach. Bugs get introduced during refactoring. The application layer is a fragile place to enforce a hard security boundary.",[15,17080,17081],{},"Row-Level Security (RLS) moves this enforcement to the database layer. Even if the application forgets to filter, the database enforces the policy and the user only sees their own data.",[22,17083,17085],{"id":17084},"what-rls-does","What RLS Does",[15,17087,17088],{},"When you enable RLS on a table and define policies, PostgreSQL evaluates those policies for every query on that table. A policy defines which rows a particular user can see, insert, update, or delete.",[15,17090,17091],{},"The database applies these policies before returning results — the application has no way to bypass them without elevated privileges.",[22,17093,17095],{"id":17094},"basic-rls-setup","Basic RLS Setup",[15,17097,17098],{},"Enable RLS on a table and create a basic policy:",[64,17100,17102],{"className":13008,"code":17101,"language":13010,"meta":69,"style":69},"-- Enable RLS on the posts table\nALTER TABLE posts ENABLE ROW LEVEL SECURITY;\n\n-- Policy: users can only see their own posts\nCREATE POLICY posts_user_isolation ON posts\n USING (author_id = current_setting('app.current_user_id')::UUID);\n",[59,17103,17104,17109,17114,17118,17123,17128],{"__ignoreMap":69},[73,17105,17106],{"class":75,"line":76},[73,17107,17108],{},"-- Enable RLS on the posts table\n",[73,17110,17111],{"class":75,"line":110},[73,17112,17113],{},"ALTER TABLE posts ENABLE ROW LEVEL SECURITY;\n",[73,17115,17116],{"class":75,"line":126},[73,17117,130],{"emptyLinePlaceholder":129},[73,17119,17120],{"class":75,"line":133},[73,17121,17122],{},"-- Policy: users can only see their own posts\n",[73,17124,17125],{"class":75,"line":142},[73,17126,17127],{},"CREATE POLICY posts_user_isolation ON posts\n",[73,17129,17130],{"class":75,"line":157},[73,17131,17132],{}," USING (author_id = current_setting('app.current_user_id')::UUID);\n",[15,17134,57,17135,17138,17139,17142],{},[59,17136,17137],{},"USING"," clause defines the filter for SELECT, UPDATE, and DELETE. The ",[59,17140,17141],{},"current_setting"," function reads a session variable that your application sets at the start of each request.",[15,17144,17145],{},"In your application, set the session variable before running queries:",[64,17147,17149],{"className":97,"code":17148,"language":99,"meta":69,"style":69},"// Set the current user for RLS\nasync function withUserContext\u003CT>(\n userId: string,\n fn: () => Promise\u003CT>\n): Promise\u003CT> {\n return await prisma.$transaction(async (tx) => {\n await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`\n return fn()\n })\n}\n\n// Usage\nconst posts = await withUserContext(userId, () =>\n prisma.post.findMany() // RLS automatically filters to this user's posts\n)\n",[59,17150,17151,17156,17173,17183,17202,17216,17242,17261,17269,17273,17277,17281,17286,17303,17316],{"__ignoreMap":69},[73,17152,17153],{"class":75,"line":76},[73,17154,17155],{"class":106},"// Set the current user for RLS\n",[73,17157,17158,17160,17162,17165,17167,17170],{"class":75,"line":110},[73,17159,1996],{"class":631},[73,17161,939],{"class":631},[73,17163,17164],{"class":79}," withUserContext",[73,17166,1115],{"class":116},[73,17168,17169],{"class":79},"T",[73,17171,17172],{"class":116},">(\n",[73,17174,17175,17177,17179,17181],{"class":75,"line":126},[73,17176,9078],{"class":624},[73,17178,2425],{"class":631},[73,17180,9769],{"class":87},[73,17182,154],{"class":116},[73,17184,17185,17188,17190,17192,17194,17196,17198,17200],{"class":75,"line":133},[73,17186,17187],{"class":79}," fn",[73,17189,2425],{"class":631},[73,17191,5401],{"class":116},[73,17193,632],{"class":631},[73,17195,10760],{"class":79},[73,17197,1115],{"class":116},[73,17199,17169],{"class":79},[73,17201,1133],{"class":116},[73,17203,17204,17206,17208,17210,17212,17214],{"class":75,"line":142},[73,17205,8859],{"class":116},[73,17207,2425],{"class":631},[73,17209,10760],{"class":79},[73,17211,1115],{"class":116},[73,17213,17169],{"class":79},[73,17215,10767],{"class":116},[73,17217,17218,17220,17222,17224,17227,17229,17231,17233,17236,17238,17240],{"class":75,"line":157},[73,17219,1084],{"class":631},[73,17221,1401],{"class":631},[73,17223,15974],{"class":116},[73,17225,17226],{"class":79},"$transaction",[73,17228,977],{"class":116},[73,17230,1996],{"class":631},[73,17232,1817],{"class":116},[73,17234,17235],{"class":624},"tx",[73,17237,1715],{"class":116},[73,17239,632],{"class":631},[73,17241,268],{"class":116},[73,17243,17244,17246,17249,17252,17255,17258],{"class":75,"line":165},[73,17245,1401],{"class":631},[73,17247,17248],{"class":116}," tx.",[73,17250,17251],{"class":79},"$executeRaw",[73,17253,17254],{"class":83},"`SELECT set_config('app.current_user_id', ${",[73,17256,17257],{"class":116},"userId",[73,17259,17260],{"class":83},"}, true)`\n",[73,17262,17263,17265,17267],{"class":75,"line":178},[73,17264,1084],{"class":631},[73,17266,17187],{"class":79},[73,17268,1154],{"class":116},[73,17270,17271],{"class":75,"line":191},[73,17272,997],{"class":116},[73,17274,17275],{"class":75,"line":204},[73,17276,1098],{"class":116},[73,17278,17279],{"class":75,"line":217},[73,17280,130],{"emptyLinePlaceholder":129},[73,17282,17283],{"class":75,"line":230},[73,17284,17285],{"class":106},"// Usage\n",[73,17287,17288,17290,17292,17294,17296,17298,17301],{"class":75,"line":243},[73,17289,1138],{"class":631},[73,17291,8266],{"class":87},[73,17293,956],{"class":631},[73,17295,1401],{"class":631},[73,17297,17164],{"class":79},[73,17299,17300],{"class":116},"(userId, () ",[73,17302,2595],{"class":631},[73,17304,17305,17308,17311,17313],{"class":75,"line":256},[73,17306,17307],{"class":116}," prisma.post.",[73,17309,17310],{"class":79},"findMany",[73,17312,10791],{"class":116},[73,17314,17315],{"class":106},"// RLS automatically filters to this user's posts\n",[73,17317,17318],{"class":75,"line":265},[73,17319,1370],{"class":116},[15,17321,57,17322,17324,17325,17328],{},[59,17323,437],{}," third argument to ",[59,17326,17327],{},"set_config"," makes the setting session-local — it resets when the transaction ends.",[22,17330,17332],{"id":17331},"separate-policies-for-different-operations","Separate Policies for Different Operations",[15,17334,17335],{},"RLS policies can be separated by operation:",[64,17337,17339],{"className":13008,"code":17338,"language":13010,"meta":69,"style":69},"-- SELECT: users see their own posts\nCREATE POLICY posts_select ON posts FOR SELECT\n USING (author_id = current_setting('app.current_user_id')::UUID);\n\n-- INSERT: users can only insert posts they are the author of\nCREATE POLICY posts_insert ON posts FOR INSERT\n WITH CHECK (author_id = current_setting('app.current_user_id')::UUID);\n\n-- UPDATE: users can only update their own posts\nCREATE POLICY posts_update ON posts FOR UPDATE\n USING (author_id = current_setting('app.current_user_id')::UUID)\n WITH CHECK (author_id = current_setting('app.current_user_id')::UUID);\n\n-- DELETE: users can only delete their own posts\nCREATE POLICY posts_delete ON posts FOR DELETE\n USING (author_id = current_setting('app.current_user_id')::UUID);\n",[59,17340,17341,17346,17351,17355,17359,17364,17369,17374,17378,17383,17388,17393,17397,17401,17406,17411],{"__ignoreMap":69},[73,17342,17343],{"class":75,"line":76},[73,17344,17345],{},"-- SELECT: users see their own posts\n",[73,17347,17348],{"class":75,"line":110},[73,17349,17350],{},"CREATE POLICY posts_select ON posts FOR SELECT\n",[73,17352,17353],{"class":75,"line":126},[73,17354,17132],{},[73,17356,17357],{"class":75,"line":133},[73,17358,130],{"emptyLinePlaceholder":129},[73,17360,17361],{"class":75,"line":142},[73,17362,17363],{},"-- INSERT: users can only insert posts they are the author of\n",[73,17365,17366],{"class":75,"line":157},[73,17367,17368],{},"CREATE POLICY posts_insert ON posts FOR INSERT\n",[73,17370,17371],{"class":75,"line":165},[73,17372,17373],{}," WITH CHECK (author_id = current_setting('app.current_user_id')::UUID);\n",[73,17375,17376],{"class":75,"line":178},[73,17377,130],{"emptyLinePlaceholder":129},[73,17379,17380],{"class":75,"line":191},[73,17381,17382],{},"-- UPDATE: users can only update their own posts\n",[73,17384,17385],{"class":75,"line":204},[73,17386,17387],{},"CREATE POLICY posts_update ON posts FOR UPDATE\n",[73,17389,17390],{"class":75,"line":217},[73,17391,17392],{}," USING (author_id = current_setting('app.current_user_id')::UUID)\n",[73,17394,17395],{"class":75,"line":230},[73,17396,17373],{},[73,17398,17399],{"class":75,"line":243},[73,17400,130],{"emptyLinePlaceholder":129},[73,17402,17403],{"class":75,"line":256},[73,17404,17405],{},"-- DELETE: users can only delete their own posts\n",[73,17407,17408],{"class":75,"line":265},[73,17409,17410],{},"CREATE POLICY posts_delete ON posts FOR DELETE\n",[73,17412,17413],{"class":75,"line":271},[73,17414,17132],{},[15,17416,57,17417,17419,17420,17423],{},[59,17418,17137],{}," clause filters which rows are visible for read operations. The ",[59,17421,17422],{},"WITH CHECK"," clause validates which rows can be written.",[22,17425,17427],{"id":17426},"multi-tenant-rls","Multi-Tenant RLS",[15,17429,17430],{},"For SaaS applications with multiple tenants, RLS enforces tenant isolation:",[64,17432,17434],{"className":13008,"code":17433,"language":13010,"meta":69,"style":69},"-- Add tenant_id to every tenant-specific table\nALTER TABLE posts ADD COLUMN tenant_id UUID NOT NULL;\nALTER TABLE users ADD COLUMN tenant_id UUID NOT NULL;\n\n-- Enable RLS\nALTER TABLE posts ENABLE ROW LEVEL SECURITY;\nALTER TABLE users ENABLE ROW LEVEL SECURITY;\n\n-- Policy: users see only records in their tenant\nCREATE POLICY tenant_isolation ON posts\n USING (tenant_id = current_setting('app.tenant_id')::UUID);\n\nCREATE POLICY tenant_isolation ON users\n USING (tenant_id = current_setting('app.tenant_id')::UUID);\n",[59,17435,17436,17441,17446,17451,17455,17460,17464,17469,17473,17478,17483,17488,17492,17497],{"__ignoreMap":69},[73,17437,17438],{"class":75,"line":76},[73,17439,17440],{},"-- Add tenant_id to every tenant-specific table\n",[73,17442,17443],{"class":75,"line":110},[73,17444,17445],{},"ALTER TABLE posts ADD COLUMN tenant_id UUID NOT NULL;\n",[73,17447,17448],{"class":75,"line":126},[73,17449,17450],{},"ALTER TABLE users ADD COLUMN tenant_id UUID NOT NULL;\n",[73,17452,17453],{"class":75,"line":133},[73,17454,130],{"emptyLinePlaceholder":129},[73,17456,17457],{"class":75,"line":142},[73,17458,17459],{},"-- Enable RLS\n",[73,17461,17462],{"class":75,"line":157},[73,17463,17113],{},[73,17465,17466],{"class":75,"line":165},[73,17467,17468],{},"ALTER TABLE users ENABLE ROW LEVEL SECURITY;\n",[73,17470,17471],{"class":75,"line":178},[73,17472,130],{"emptyLinePlaceholder":129},[73,17474,17475],{"class":75,"line":191},[73,17476,17477],{},"-- Policy: users see only records in their tenant\n",[73,17479,17480],{"class":75,"line":204},[73,17481,17482],{},"CREATE POLICY tenant_isolation ON posts\n",[73,17484,17485],{"class":75,"line":217},[73,17486,17487],{}," USING (tenant_id = current_setting('app.tenant_id')::UUID);\n",[73,17489,17490],{"class":75,"line":230},[73,17491,130],{"emptyLinePlaceholder":129},[73,17493,17494],{"class":75,"line":243},[73,17495,17496],{},"CREATE POLICY tenant_isolation ON users\n",[73,17498,17499],{"class":75,"line":256},[73,17500,17487],{},[15,17502,17503],{},"Set the tenant context in your request middleware:",[64,17505,17507],{"className":97,"code":17506,"language":99,"meta":69,"style":69},"// server/middleware/tenant.ts\nexport default defineEventHandler(async (event) => {\n const session = await getSession(event)\n if (!session) return\n\n // Store tenant context for database middleware to use\n event.context.tenantId = session.user.tenantId\n event.context.userId = session.user.id\n})\n\n// Apply context before database operations\nasync function withTenantContext\u003CT>(\n tenantId: string,\n userId: string,\n fn: () => Promise\u003CT>\n): Promise\u003CT> {\n return prisma.$transaction(async (tx) => {\n await tx.$executeRaw`\n SELECT\n set_config('app.tenant_id', ${tenantId}, true),\n set_config('app.current_user_id', ${userId}, true)\n `\n return fn()\n })\n}\n",[59,17508,17509,17514,17537,17552,17565,17569,17574,17584,17594,17598,17602,17607,17622,17633,17643,17661,17675,17697,17707,17711,17722,17732,17736,17744,17748],{"__ignoreMap":69},[73,17510,17511],{"class":75,"line":76},[73,17512,17513],{"class":106},"// server/middleware/tenant.ts\n",[73,17515,17516,17518,17521,17523,17525,17527,17529,17531,17533,17535],{"class":75,"line":110},[73,17517,936],{"class":631},[73,17519,17520],{"class":631}," default",[73,17522,2214],{"class":79},[73,17524,977],{"class":116},[73,17526,1996],{"class":631},[73,17528,1817],{"class":116},[73,17530,2223],{"class":624},[73,17532,1715],{"class":116},[73,17534,632],{"class":631},[73,17536,268],{"class":116},[73,17538,17539,17541,17543,17545,17547,17550],{"class":75,"line":126},[73,17540,950],{"class":631},[73,17542,9087],{"class":87},[73,17544,956],{"class":631},[73,17546,1401],{"class":631},[73,17548,17549],{"class":79}," getSession",[73,17551,2255],{"class":116},[73,17553,17554,17556,17558,17560,17563],{"class":75,"line":133},[73,17555,1814],{"class":631},[73,17557,1817],{"class":116},[73,17559,1820],{"class":631},[73,17561,17562],{"class":116},"session) ",[73,17564,1826],{"class":631},[73,17566,17567],{"class":75,"line":142},[73,17568,130],{"emptyLinePlaceholder":129},[73,17570,17571],{"class":75,"line":157},[73,17572,17573],{"class":106}," // Store tenant context for database middleware to use\n",[73,17575,17576,17579,17581],{"class":75,"line":165},[73,17577,17578],{"class":116}," event.context.tenantId ",[73,17580,991],{"class":631},[73,17582,17583],{"class":116}," session.user.tenantId\n",[73,17585,17586,17589,17591],{"class":75,"line":178},[73,17587,17588],{"class":116}," event.context.userId ",[73,17590,991],{"class":631},[73,17592,17593],{"class":116}," session.user.id\n",[73,17595,17596],{"class":75,"line":191},[73,17597,1379],{"class":116},[73,17599,17600],{"class":75,"line":204},[73,17601,130],{"emptyLinePlaceholder":129},[73,17603,17604],{"class":75,"line":217},[73,17605,17606],{"class":106},"// Apply context before database operations\n",[73,17608,17609,17611,17613,17616,17618,17620],{"class":75,"line":230},[73,17610,1996],{"class":631},[73,17612,939],{"class":631},[73,17614,17615],{"class":79}," withTenantContext",[73,17617,1115],{"class":116},[73,17619,17169],{"class":79},[73,17621,17172],{"class":116},[73,17623,17624,17627,17629,17631],{"class":75,"line":243},[73,17625,17626],{"class":624}," tenantId",[73,17628,2425],{"class":631},[73,17630,9769],{"class":87},[73,17632,154],{"class":116},[73,17634,17635,17637,17639,17641],{"class":75,"line":256},[73,17636,9078],{"class":624},[73,17638,2425],{"class":631},[73,17640,9769],{"class":87},[73,17642,154],{"class":116},[73,17644,17645,17647,17649,17651,17653,17655,17657,17659],{"class":75,"line":265},[73,17646,17187],{"class":79},[73,17648,2425],{"class":631},[73,17650,5401],{"class":116},[73,17652,632],{"class":631},[73,17654,10760],{"class":79},[73,17656,1115],{"class":116},[73,17658,17169],{"class":79},[73,17660,1133],{"class":116},[73,17662,17663,17665,17667,17669,17671,17673],{"class":75,"line":271},[73,17664,8859],{"class":116},[73,17666,2425],{"class":631},[73,17668,10760],{"class":79},[73,17670,1115],{"class":116},[73,17672,17169],{"class":79},[73,17674,10767],{"class":116},[73,17676,17677,17679,17681,17683,17685,17687,17689,17691,17693,17695],{"class":75,"line":282},[73,17678,1084],{"class":631},[73,17680,15974],{"class":116},[73,17682,17226],{"class":79},[73,17684,977],{"class":116},[73,17686,1996],{"class":631},[73,17688,1817],{"class":116},[73,17690,17235],{"class":624},[73,17692,1715],{"class":116},[73,17694,632],{"class":631},[73,17696,268],{"class":116},[73,17698,17699,17701,17703,17705],{"class":75,"line":293},[73,17700,1401],{"class":631},[73,17702,17248],{"class":116},[73,17704,17251],{"class":79},[73,17706,15988],{"class":83},[73,17708,17709],{"class":75,"line":304},[73,17710,15993],{"class":83},[73,17712,17713,17716,17719],{"class":75,"line":310},[73,17714,17715],{"class":83}," set_config('app.tenant_id', ${",[73,17717,17718],{"class":116},"tenantId",[73,17720,17721],{"class":83},"}, true),\n",[73,17723,17724,17727,17729],{"class":75,"line":315},[73,17725,17726],{"class":83}," set_config('app.current_user_id', ${",[73,17728,17257],{"class":116},[73,17730,17731],{"class":83},"}, true)\n",[73,17733,17734],{"class":75,"line":325},[73,17735,16090],{"class":83},[73,17737,17738,17740,17742],{"class":75,"line":335},[73,17739,1084],{"class":631},[73,17741,17187],{"class":79},[73,17743,1154],{"class":116},[73,17745,17746],{"class":75,"line":344},[73,17747,997],{"class":116},[73,17749,17750],{"class":75,"line":349},[73,17751,1098],{"class":116},[22,17753,17755],{"id":17754},"admin-bypass","Admin Bypass",[15,17757,17758],{},"Administrators often need to see all records regardless of RLS policies. The clean way to handle this is with a separate database role:",[64,17760,17762],{"className":13008,"code":17761,"language":13010,"meta":69,"style":69},"-- Create an admin role that bypasses RLS\nCREATE ROLE app_admin;\nGRANT app_admin TO your_application_user;\n\n-- RLS does not apply to table owner or roles with BYPASSRLS\nALTER ROLE app_admin BYPASSRLS;\n",[59,17763,17764,17769,17774,17779,17783,17788],{"__ignoreMap":69},[73,17765,17766],{"class":75,"line":76},[73,17767,17768],{},"-- Create an admin role that bypasses RLS\n",[73,17770,17771],{"class":75,"line":110},[73,17772,17773],{},"CREATE ROLE app_admin;\n",[73,17775,17776],{"class":75,"line":126},[73,17777,17778],{},"GRANT app_admin TO your_application_user;\n",[73,17780,17781],{"class":75,"line":133},[73,17782,130],{"emptyLinePlaceholder":129},[73,17784,17785],{"class":75,"line":142},[73,17786,17787],{},"-- RLS does not apply to table owner or roles with BYPASSRLS\n",[73,17789,17790],{"class":75,"line":157},[73,17791,17792],{},"ALTER ROLE app_admin BYPASSRLS;\n",[15,17794,17795],{},"Or use a policy that allows admins:",[64,17797,17799],{"className":13008,"code":17798,"language":13010,"meta":69,"style":69},"CREATE POLICY posts_policy ON posts\n USING (\n author_id = current_setting('app.current_user_id')::UUID\n OR\n current_setting('app.user_role') = 'admin'\n );\n",[59,17800,17801,17806,17811,17816,17821,17826],{"__ignoreMap":69},[73,17802,17803],{"class":75,"line":76},[73,17804,17805],{},"CREATE POLICY posts_policy ON posts\n",[73,17807,17808],{"class":75,"line":110},[73,17809,17810],{}," USING (\n",[73,17812,17813],{"class":75,"line":126},[73,17814,17815],{}," author_id = current_setting('app.current_user_id')::UUID\n",[73,17817,17818],{"class":75,"line":133},[73,17819,17820],{}," OR\n",[73,17822,17823],{"class":75,"line":142},[73,17824,17825],{}," current_setting('app.user_role') = 'admin'\n",[73,17827,17828],{"class":75,"line":157},[73,17829,17830],{}," );\n",[15,17832,17833,17834,17837,17838,11299,17841,17844],{},"For Supabase users, the ",[59,17835,17836],{},"service_role"," key bypasses RLS — this is intentional for backend admin operations. Always use the ",[59,17839,17840],{},"anon",[59,17842,17843],{},"authenticated"," keys from client-side code.",[22,17846,17848],{"id":17847},"rls-with-orms","RLS With ORMs",[15,17850,17851],{},"RLS works transparently with ORMs. The ORM runs queries normally; the database applies the policies before returning results.",[15,17853,17854],{},"With Prisma, wrap every operation in a context-setting transaction:",[64,17856,17858],{"className":97,"code":17857,"language":99,"meta":69,"style":69},"// lib/db.ts\nexport async function createUserPrisma(userId: string, tenantId: string) {\n return prisma.$extends({\n query: {\n $allModels: {\n async $allOperations({ query, args }) {\n const [, result] = await prisma.$transaction([\n prisma.$executeRaw`\n SELECT\n set_config('app.current_user_id', ${userId}, true),\n set_config('app.tenant_id', ${tenantId}, true)\n `,\n query(args),\n ])\n return result\n },\n },\n },\n })\n}\n\n// In your request handler\nconst db = createUserPrisma(session.userId, session.tenantId)\nconst posts = await db.post.findMany() // RLS automatically applied\n",[59,17859,17860,17865,17894,17905,17910,17915,17935,17958,17966,17970,17978,17986,17993,18000,18005,18012,18016,18020,18024,18028,18032,18036,18041,18055],{"__ignoreMap":69},[73,17861,17862],{"class":75,"line":76},[73,17863,17864],{"class":106},"// lib/db.ts\n",[73,17866,17867,17869,17871,17873,17876,17878,17880,17882,17884,17886,17888,17890,17892],{"class":75,"line":110},[73,17868,936],{"class":631},[73,17870,1802],{"class":631},[73,17872,939],{"class":631},[73,17874,17875],{"class":79}," createUserPrisma",[73,17877,977],{"class":116},[73,17879,17257],{"class":624},[73,17881,2425],{"class":631},[73,17883,9769],{"class":87},[73,17885,710],{"class":116},[73,17887,17718],{"class":624},[73,17889,2425],{"class":631},[73,17891,9769],{"class":87},[73,17893,1349],{"class":116},[73,17895,17896,17898,17900,17903],{"class":75,"line":126},[73,17897,1084],{"class":631},[73,17899,15974],{"class":116},[73,17901,17902],{"class":79},"$extends",[73,17904,1336],{"class":116},[73,17906,17907],{"class":75,"line":133},[73,17908,17909],{"class":116}," query: {\n",[73,17911,17912],{"class":75,"line":142},[73,17913,17914],{"class":116}," $allModels: {\n",[73,17916,17917,17919,17922,17925,17927,17929,17932],{"class":75,"line":157},[73,17918,1802],{"class":631},[73,17920,17921],{"class":79}," $allOperations",[73,17923,17924],{"class":116},"({ ",[73,17926,9976],{"class":624},[73,17928,710],{"class":116},[73,17930,17931],{"class":624},"args",[73,17933,17934],{"class":116}," }) {\n",[73,17936,17937,17939,17942,17945,17948,17950,17952,17954,17956],{"class":75,"line":165},[73,17938,950],{"class":631},[73,17940,17941],{"class":116}," [, ",[73,17943,17944],{"class":87},"result",[73,17946,17947],{"class":116},"] ",[73,17949,991],{"class":631},[73,17951,1401],{"class":631},[73,17953,15974],{"class":116},[73,17955,17226],{"class":79},[73,17957,3076],{"class":116},[73,17959,17960,17962,17964],{"class":75,"line":178},[73,17961,15974],{"class":116},[73,17963,17251],{"class":79},[73,17965,15988],{"class":83},[73,17967,17968],{"class":75,"line":191},[73,17969,15993],{"class":83},[73,17971,17972,17974,17976],{"class":75,"line":204},[73,17973,17726],{"class":83},[73,17975,17257],{"class":116},[73,17977,17721],{"class":83},[73,17979,17980,17982,17984],{"class":75,"line":217},[73,17981,17715],{"class":83},[73,17983,17718],{"class":116},[73,17985,17731],{"class":83},[73,17987,17988,17991],{"class":75,"line":230},[73,17989,17990],{"class":83}," `",[73,17992,154],{"class":116},[73,17994,17995,17997],{"class":75,"line":243},[73,17996,12003],{"class":79},[73,17998,17999],{"class":116},"(args),\n",[73,18001,18002],{"class":75,"line":256},[73,18003,18004],{"class":116}," ])\n",[73,18006,18007,18009],{"class":75,"line":265},[73,18008,1084],{"class":631},[73,18010,18011],{"class":116}," result\n",[73,18013,18014],{"class":75,"line":271},[73,18015,307],{"class":116},[73,18017,18018],{"class":75,"line":282},[73,18019,307],{"class":116},[73,18021,18022],{"class":75,"line":293},[73,18023,307],{"class":116},[73,18025,18026],{"class":75,"line":304},[73,18027,997],{"class":116},[73,18029,18030],{"class":75,"line":310},[73,18031,1098],{"class":116},[73,18033,18034],{"class":75,"line":315},[73,18035,130],{"emptyLinePlaceholder":129},[73,18037,18038],{"class":75,"line":325},[73,18039,18040],{"class":106},"// In your request handler\n",[73,18042,18043,18045,18048,18050,18052],{"class":75,"line":335},[73,18044,1138],{"class":631},[73,18046,18047],{"class":87}," db",[73,18049,956],{"class":631},[73,18051,17875],{"class":79},[73,18053,18054],{"class":116},"(session.userId, session.tenantId)\n",[73,18056,18057,18059,18061,18063,18065,18068,18070,18072],{"class":75,"line":344},[73,18058,1138],{"class":631},[73,18060,8266],{"class":87},[73,18062,956],{"class":631},[73,18064,1401],{"class":631},[73,18066,18067],{"class":116}," db.post.",[73,18069,17310],{"class":79},[73,18071,10791],{"class":116},[73,18073,18074],{"class":106},"// RLS automatically applied\n",[15,18076,18077],{},"The Prisma extension approach creates a client instance with user context baked in — all queries from that client automatically have the right RLS context.",[22,18079,18081],{"id":18080},"performance-considerations","Performance Considerations",[15,18083,18084],{},"RLS policies add a WHERE clause equivalent to every query. Well-indexed RLS columns (tenant_id, author_id) make this overhead minimal. Poor indexing makes it expensive.",[15,18086,18087],{},"Always ensure the columns referenced in your policies are indexed:",[64,18089,18091],{"className":13008,"code":18090,"language":13010,"meta":69,"style":69},"CREATE INDEX idx_posts_tenant_id ON posts(tenant_id);\nCREATE INDEX idx_posts_author_id ON posts(author_id);\n",[59,18092,18093,18098],{"__ignoreMap":69},[73,18094,18095],{"class":75,"line":76},[73,18096,18097],{},"CREATE INDEX idx_posts_tenant_id ON posts(tenant_id);\n",[73,18099,18100],{"class":75,"line":110},[73,18101,18102],{},"CREATE INDEX idx_posts_author_id ON posts(author_id);\n",[15,18104,18105],{},"Verify your policies are not preventing index usage:",[64,18107,18109],{"className":13008,"code":18108,"language":13010,"meta":69,"style":69},"-- Set context for testing\nSELECT set_config('app.current_user_id', '123e4567-e89b-12d3-a456-426614174000', true);\n\nEXPLAIN ANALYZE\nSELECT * FROM posts WHERE status = 'published' ORDER BY created_at DESC LIMIT 20;\n",[59,18110,18111,18116,18121,18125,18130],{"__ignoreMap":69},[73,18112,18113],{"class":75,"line":76},[73,18114,18115],{},"-- Set context for testing\n",[73,18117,18118],{"class":75,"line":110},[73,18119,18120],{},"SELECT set_config('app.current_user_id', '123e4567-e89b-12d3-a456-426614174000', true);\n",[73,18122,18123],{"class":75,"line":126},[73,18124,130],{"emptyLinePlaceholder":129},[73,18126,18127],{"class":75,"line":133},[73,18128,18129],{},"EXPLAIN ANALYZE\n",[73,18131,18132],{"class":75,"line":142},[73,18133,18134],{},"SELECT * FROM posts WHERE status = 'published' ORDER BY created_at DESC LIMIT 20;\n",[15,18136,18137],{},"If RLS causes a seq scan where you would otherwise have an index scan, adjust your policy or add a composite index that covers both the RLS filter and the query filter.",[22,18139,18141],{"id":18140},"when-to-use-rls","When to Use RLS",[15,18143,18144],{},"RLS is particularly valuable for:",[15,18146,18147,18150],{},[32,18148,18149],{},"Multi-tenant SaaS applications:"," Tenant isolation enforced at the database layer is the most reliable defense against cross-tenant data access bugs.",[15,18152,18153,18156],{},[32,18154,18155],{},"Healthcare and financial applications:"," When regulatory compliance requires data isolation, RLS provides a documented, enforceable boundary.",[15,18158,18159,18162],{},[32,18160,18161],{},"Applications with complex access control:"," When access rules are complex but stable, encoding them in database policies is more reliable than application code.",[15,18164,18165,18168],{},[32,18166,18167],{},"Supabase applications:"," Supabase's architecture assumes RLS and provides tooling that makes it easy to use.",[15,18170,18171],{},"RLS is not a replacement for application-layer authorization — you still need to verify that a user is allowed to perform an action before trying to perform it. But it is an excellent defense-in-depth layer that catches bugs that would otherwise become data breaches.",[2326,18173],{},[15,18175,18176,18177,2274],{},"Designing the security architecture for a multi-tenant application or implementing RLS for the first time? I can help you think through the policy design. Book a call: ",[2332,18178,2337],{"href":2334,"rel":18179},[2336],[2326,18181],{},[22,18183,2343],{"id":2342},[2304,18185,18186,18192,18196,18200],{},[2307,18187,18188],{},[2332,18189,18191],{"href":18190},"/blog/database-transactions-guide","Database Transactions: ACID, Isolation Levels, and When It All Goes Wrong",[2307,18193,18194],{},[2332,18195,16339],{"href":16338},[2307,18197,18198],{},[2332,18199,16345],{"href":16344},[2307,18201,18202],{},[2332,18203,16351],{"href":16350},[2371,18205,18206],{},"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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":69,"searchDepth":126,"depth":126,"links":18208},[18209,18210,18211,18212,18213,18214,18215,18216,18217],{"id":17084,"depth":110,"text":17085},{"id":17094,"depth":110,"text":17095},{"id":17331,"depth":110,"text":17332},{"id":17426,"depth":110,"text":17427},{"id":17754,"depth":110,"text":17755},{"id":17847,"depth":110,"text":17848},{"id":18080,"depth":110,"text":18081},{"id":18140,"depth":110,"text":18141},{"id":2342,"depth":110,"text":2343},"A practical guide to PostgreSQL Row-Level Security — enabling RLS, writing policies, bypassing for admin roles, and using RLS to enforce multi-tenant data isolation.",[18220,18221],"PostgreSQL row level security","database security",{},{"title":16333,"description":18218},"blog/postgresql-row-level-security",[16375,11373,16377],"IUUuaCtqkL8CBu_mBEgVkQN-5bKqDMMQuvLEtMXPE4U",[18228,18230,18232,18233,18234,18236,18237,18238,18239,18240,18241,18242,18243,18244,18245,18246,18247,18248,18249,18250,18251,18252,18253,18254,18255,18256,18257,18258,18259,18260,18261,18262,18263,18264,18265,18266,18267,18268,18269,18270,18271,18272,18273,18274,18275,18276,18277,18278,18279,18280,18281,18282,18283,18284,18285,18286,18287,18288,18289,18290,18291,18292,18293,18294,18295,18296,18297,18298,18299,18300,18301,18302,18303,18304,18305,18306,18307,18308,18309,18310,18311,18312,18314,18315,18316,18317,18318,18319,18320,18321,18322,18323,18324,18325,18326,18327,18328,18329,18330,18331,18332,18333,18334,18335,18336,18337,18338,18339,18340,18341,18342,18343,18344,18345,18346,18347,18348,18349,18350,18351,18352,18353,18354,18355,18356,18357,18358,18359,18360,18361,18362,18363,18364,18365,18366,18367,18368,18369,18370,18371,18372,18373,18374,18375,18376,18377,18378,18379,18380,18381,18382,18383,18384,18385,18386,18387,18388,18389,18390,18391,18392,18393,18394,18395,18396,18397,18398,18399,18400,18401,18402,18403,18404,18405,18406,18407,18408,18409,18410,18411,18412,18413,18414,18415,18416,18417,18418,18419,18420,18421,18422,18423,18424,18425,18426,18427,18428,18429,18430,18431,18432,18433,18434,18435,18436,18437,18438,18439,18440,18441,18442,18443,18444,18445,18446,18447,18448,18449,18450,18451,18452,18453,18454,18455,18456,18457,18458,18459,18460,18461,18462,18463,18464,18465,18466,18467,18468,18469,18470,18471,18472,18473,18474,18475,18476,18477,18478,18479,18480,18481,18482,18483,18484,18485,18486,18487,18488,18489,18490,18491,18492,18493,18494,18495,18496,18497,18498,18499,18500,18501,18502,18503,18504,18505,18506,18507,18508,18509,18510,18511,18512,18513,18514,18515,18516,18517,18518,18519,18520,18521,18522,18523,18524,18525,18526,18527,18528,18529,18530,18531,18532,18533,18534,18535,18536,18537,18538,18539,18540,18541,18542,18543,18544,18545,18546,18547,18548,18549,18550,18551,18552,18553,18554,18555,18556,18557,18558,18559,18560,18561,18562,18563,18564,18565,18566,18567,18568,18569,18570,18571,18572,18573,18574,18575,18576,18577,18578,18579,18580,18581,18582,18583,18584,18585,18586,18587,18588,18589,18590,18591,18592,18593,18594,18595,18596,18597,18598,18599,18600,18601,18602,18603,18604,18605,18606,18607,18608,18609,18610,18611,18612,18613,18614,18615,18616,18617,18618,18619,18620,18621,18622,18623,18624,18625,18626,18627,18628,18629,18630,18631,18632,18633,18634,18635,18636,18637,18638,18639,18640,18641,18642,18643,18644,18645,18646,18647,18648,18649,18650,18651,18652,18653,18654,18655,18656,18657,18658,18659,18660,18661,18662,18663,18664,18665,18666,18667,18668,18669,18670,18671,18672,18673,18674,18675,18676,18677,18678,18679,18680,18681,18682,18683,18684,18685,18686,18687,18688,18689,18690,18691,18692,18693,18694,18695,18696,18697,18698,18699,18700,18701,18702,18704,18705,18706,18707,18708,18709,18710,18711,18712,18713,18714,18715,18716,18717,18718,18719,18720,18721,18722,18723,18724,18725,18726,18727,18728,18729,18730,18731,18732,18733,18734,18735,18736,18737,18738,18739,18740,18741,18742,18743,18744,18745,18746,18747,18748,18749,18750,18751,18752,18753,18754,18755,18756,18757,18758,18759,18760,18761,18762,18763,18764,18765,18766,18767,18768,18769,18770,18771,18772,18773,18774,18775,18776,18777,18778,18779,18780,18781,18782,18783,18784,18785,18786,18787,18788,18789,18790,18791,18792,18793,18794,18795,18796,18797,18798,18799,18800,18801,18802,18803,18804,18805,18806,18807,18808,18809,18810,18811,18812,18813,18814,18815,18816,18817,18818,18819,18820,18821,18822,18823,18824,18825,18826,18827,18828,18829,18830,18831,18832,18833,18834,18835,18836,18837,18838,18839,18840,18841,18842,18843,18844,18845,18846,18847,18848,18849,18850,18851,18852,18853,18854,18855,18856,18857,18858,18859,18860,18861,18862,18863,18864,18865,18866,18867,18868,18869,18870,18871,18872],{"category":18229},"Frontend",{"category":18231},"Heritage",{"category":11620},{"category":2385},{"category":18235},"Business",{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":11620},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":4386},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":11373},{"category":11373},{"category":18235},{"category":18235},{"category":18231},{"category":11373},{"category":18231},{"category":4386},{"category":11373},{"category":2385},{"category":18235},{"category":13398},{"category":11620},{"category":18231},{"category":2385},{"category":4386},{"category":2385},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":13398},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":2385},{"category":18313},"Career",{"category":11620},{"category":11620},{"category":18235},{"category":4386},{"category":18235},{"category":2385},{"category":2385},{"category":18235},{"category":2385},{"category":4386},{"category":2385},{"category":13398},{"category":13398},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":11620},{"category":4386},{"category":18235},{"category":13398},{"category":13398},{"category":13398},{"category":18231},{"category":2385},{"category":2385},{"category":18231},{"category":18229},{"category":11620},{"category":13398},{"category":13398},{"category":11373},{"category":13398},{"category":18235},{"category":11620},{"category":18231},{"category":2385},{"category":18231},{"category":4386},{"category":18231},{"category":4386},{"category":11373},{"category":18231},{"category":18231},{"category":2385},{"category":18235},{"category":2385},{"category":18229},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":18235},{"category":18235},{"category":18231},{"category":18229},{"category":11373},{"category":4386},{"category":11373},{"category":18229},{"category":2385},{"category":2385},{"category":13398},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":13398},{"category":2385},{"category":2385},{"category":18231},{"category":18231},{"category":11373},{"category":4386},{"category":4386},{"category":18313},{"category":18313},{"category":18313},{"category":18235},{"category":2385},{"category":13398},{"category":4386},{"category":18231},{"category":18231},{"category":13398},{"category":4386},{"category":4386},{"category":18229},{"category":2385},{"category":18231},{"category":18231},{"category":2385},{"category":18231},{"category":13398},{"category":13398},{"category":18231},{"category":11373},{"category":18231},{"category":4386},{"category":11373},{"category":4386},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":11373},{"category":2385},{"category":13398},{"category":13398},{"category":18235},{"category":2385},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":4386},{"category":4386},{"category":4386},{"category":2385},{"category":18231},{"category":18231},{"category":18231},{"category":13398},{"category":18235},{"category":18231},{"category":18231},{"category":2385},{"category":18231},{"category":2385},{"category":18229},{"category":18231},{"category":18235},{"category":18235},{"category":2385},{"category":2385},{"category":11620},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":2385},{"category":13398},{"category":13398},{"category":13398},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":18231},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18235},{"category":18235},{"category":18231},{"category":2385},{"category":18229},{"category":4386},{"category":18313},{"category":18231},{"category":18231},{"category":11373},{"category":2385},{"category":18231},{"category":18231},{"category":13398},{"category":18231},{"category":18229},{"category":13398},{"category":13398},{"category":11373},{"category":2385},{"category":2385},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18313},{"category":18231},{"category":4386},{"category":2385},{"category":2385},{"category":18231},{"category":13398},{"category":18231},{"category":18231},{"category":18231},{"category":18229},{"category":18231},{"category":18231},{"category":2385},{"category":18231},{"category":2385},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":11620},{"category":11620},{"category":2385},{"category":18231},{"category":13398},{"category":13398},{"category":18231},{"category":2385},{"category":18231},{"category":18231},{"category":11620},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":2385},{"category":11373},{"category":2385},{"category":2385},{"category":18229},{"category":2385},{"category":18229},{"category":18229},{"category":11373},{"category":4386},{"category":2385},{"category":4386},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":2385},{"category":18235},{"category":2385},{"category":2385},{"category":18231},{"category":4386},{"category":11620},{"category":11620},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18235},{"category":2385},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":18229},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":2385},{"category":4386},{"category":18231},{"category":18235},{"category":11620},{"category":18231},{"category":18235},{"category":11373},{"category":18231},{"category":11373},{"category":2385},{"category":13398},{"category":18231},{"category":18231},{"category":2385},{"category":18231},{"category":4386},{"category":18231},{"category":18231},{"category":2385},{"category":18235},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":18235},{"category":2385},{"category":2385},{"category":18235},{"category":13398},{"category":2385},{"category":11620},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":18231},{"category":18231},{"category":18231},{"category":11620},{"category":2385},{"category":2385},{"category":4386},{"category":18229},{"category":2385},{"category":18231},{"category":2385},{"category":4386},{"category":18235},{"category":18235},{"category":18229},{"category":18229},{"category":18231},{"category":18235},{"category":11373},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":2385},{"category":2385},{"category":4386},{"category":2385},{"category":2385},{"category":2385},{"category":18703},"Programming",{"category":2385},{"category":2385},{"category":4386},{"category":4386},{"category":2385},{"category":2385},{"category":18235},{"category":11373},{"category":2385},{"category":18235},{"category":2385},{"category":2385},{"category":2385},{"category":2385},{"category":13398},{"category":4386},{"category":18235},{"category":18235},{"category":2385},{"category":2385},{"category":18235},{"category":2385},{"category":11373},{"category":18235},{"category":2385},{"category":2385},{"category":4386},{"category":4386},{"category":18231},{"category":18235},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":18229},{"category":18231},{"category":13398},{"category":11373},{"category":11373},{"category":11373},{"category":11373},{"category":11373},{"category":11373},{"category":18231},{"category":2385},{"category":13398},{"category":4386},{"category":13398},{"category":4386},{"category":2385},{"category":18229},{"category":18231},{"category":4386},{"category":18229},{"category":18231},{"category":18231},{"category":18231},{"category":4386},{"category":4386},{"category":4386},{"category":18235},{"category":18235},{"category":18235},{"category":4386},{"category":4386},{"category":18235},{"category":18235},{"category":18235},{"category":18231},{"category":11373},{"category":2385},{"category":13398},{"category":2385},{"category":18231},{"category":18235},{"category":18235},{"category":18231},{"category":18231},{"category":4386},{"category":2385},{"category":4386},{"category":4386},{"category":4386},{"category":18229},{"category":2385},{"category":18231},{"category":18231},{"category":18235},{"category":18235},{"category":4386},{"category":2385},{"category":18313},{"category":4386},{"category":18313},{"category":18235},{"category":18231},{"category":4386},{"category":18231},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":18231},{"category":11620},{"category":11620},{"category":13398},{"category":18231},{"category":18231},{"category":18231},{"category":18231},{"category":2385},{"category":2385},{"category":18229},{"category":2385},{"category":11373},{"category":4386},{"category":18229},{"category":18229},{"category":2385},{"category":2385},{"category":18229},{"category":18229},{"category":18229},{"category":11373},{"category":2385},{"category":2385},{"category":18235},{"category":2385},{"category":4386},{"category":18231},{"category":18231},{"category":4386},{"category":18231},{"category":18231},{"category":4386},{"category":18231},{"category":2385},{"category":18231},{"category":11373},{"category":18231},{"category":18231},{"category":18231},{"category":13398},{"category":13398},{"category":11373},1772951194533]