[{"data":1,"prerenderedAt":2104},["ShallowReactive",2],{"header-latest":3,"post-\u002Fblog\u002Fsaas-multitenancy-databases":1372},[4],{"id":5,"title":6,"body":7,"date":1346,"description":1347,"extension":1348,"faq":1349,"image":1359,"lang":1360,"meta":1361,"navigation":108,"path":1362,"published":108,"readTime":215,"seo":1363,"stem":1364,"tags":1365,"updated":1346,"__hash__":1371},"blog\u002Fblog\u002Fen\u002Ftelegram-web-apps-sells.md","Telegram Web Apps (TWA): How to Launch a Full SaaS Inside Telegram",{"type":8,"value":9,"toc":1335},"minimark",[10,19,22,25,30,33,44,52,63,70,73,856,860,863,868,875,923,927,930,1215,1219,1229,1232,1270,1283,1291,1295,1315,1318,1331],[11,12,13,14,18],"p",{},"Telegram has transcended its origins as a privacy-focused messaging client to become a powerful, cross-platform runtime environment. The introduction of ",[15,16,17],"strong",{},"Telegram Web Apps (TWA)",", officially known as Telegram Mini Apps, allows developers to build highly interactive single-page applications (SPAs) that load directly inside the Telegram application shell.",[11,20,21],{},"This provides startups and enterprises with immediate, frictionless access to over 900 million active users. By embedding your B2B SaaS, CRM interface, or utility tool inside a Telegram bot, you remove the classic conversion hurdles of app store downloads, desktop logins, and sign-up flows.",[11,23,24],{},"In this developer guide, we will analyze the technical architecture of a TWA, implement a secure hash verification check on the backend, and look at frontend integration patterns.",[26,27,29],"h2",{"id":28},"the-telegram-mini-app-ecosystem-architecture","The Telegram Mini App Ecosystem Architecture",[11,31,32],{},"Unlike traditional web applications, a TWA has a dual parent relationship. The frontend runs in a sandboxed browser component (WebView) controlled by the Telegram client app, while the backend speaks both to the Telegram Bot API and your application database.",[34,35,41],"pre",{"className":36,"code":38,"language":39,"meta":40},[37],"language-text","+-----------------------------------------------------------+\n|                      Telegram Client                      |\n|  +-----------------------------------------------------+  |\n|  |                Mini App (WebView UI)                |  |\n|  |     (Uses window.Telegram.WebApp SDK for bridge)    |  |\n|  +--------------------------+--------------------------+  |\n+-----------------------------|-----------------------------+\n       | (initData Query)     | (Secure HTTPS Request)\n       v                      v\n+--------------+       +------------------------------------+\n| Telegram API |       |            Your Backend            |\n|  (Webhooks)  |       |  (Validates initData via HMAC-256) |\n+------+-------+       +-----------------+------------------+\n       |                                 |\n       +----------------> [DB Sync] \u003C----+\n","text","",[42,43,38],"code",{"__ignoreMap":40},[26,45,47,48,51],{"id":46},"_1-security-first-validating-the-initdata-payload","1. Security First: Validating the ",[42,49,50],{},"initData"," Payload",[11,53,54,55,58,59,62],{},"When a user opens your Mini App, Telegram appends a parameter called ",[42,56,57],{},"tgWebAppData"," (or raw search parameters) containing user profiles, launch contexts, and a security ",[42,60,61],{},"hash",".",[11,64,65,66,69],{},"To prevent users from modifying their user IDs or mocking paid subscription privileges, you ",[15,67,68],{},"must validate this hash on your backend"," using your Telegram Bot Token as the HMAC key.",[11,71,72],{},"Here is the cryptographic validation implementation using Node.js:",[34,74,78],{"className":75,"code":76,"language":77,"meta":40,"style":40},"language-typescript shiki shiki-themes github-dark","import crypto from 'crypto';\n\ninterface TelegramUserData {\n  id: number;\n  first_name: string;\n  last_name?: string;\n  username?: string;\n  language_code?: string;\n  is_premium?: boolean;\n}\n\ninterface ValidationResult {\n  isValid: boolean;\n  user?: TelegramUserData;\n}\n\nexport function verifyTelegramInitData(rawQueryString: string, botToken: string): ValidationResult {\n  const urlParams = new URLSearchParams(rawQueryString);\n  const hash = urlParams.get('hash');\n  \n  if (!hash) {\n    return { isValid: false };\n  }\n\n  \u002F\u002F 1. Sort all incoming parameters alphabetically, excluding the hash itself\n  const keys = Array.from(urlParams.keys()).filter(key => key !== 'hash').sort();\n  \n  \u002F\u002F 2. Re-create the verification data-check string\n  const dataCheckString = keys\n    .map(key => `${key}=${urlParams.get(key)}`)\n    .join('\\n');\n\n  \u002F\u002F 3. Generate the secret cryptographic key\n  \u002F\u002F We use the constant string \"WebAppData\" to sign the bot token first\n  const secretKey = crypto\n    .createHmac('sha256', 'WebAppData')\n    .update(botToken)\n    .digest();\n\n  \u002F\u002F 4. Calculate the expected hash of the sorted string\n  const computedHash = crypto\n    .createHmac('sha256', secretKey)\n    .update(dataCheckString)\n    .digest('hex');\n\n  \u002F\u002F 5. Compare computed signature with the signature sent by the client\n  const isValid = computedHash === hash;\n\n  if (!isValid) {\n    return { isValid: false };\n  }\n\n  \u002F\u002F Parse user data object if validation succeeded\n  try {\n    const userRaw = urlParams.get('user');\n    const user: TelegramUserData = userRaw ? JSON.parse(userRaw) : undefined;\n    return { isValid: true, user };\n  } catch (error) {\n    return { isValid: true };\n  }\n}\n","typescript",[42,79,80,103,110,123,139,152,165,177,189,202,208,213,223,235,247,252,257,298,319,343,349,364,379,385,390,397,450,455,461,474,516,536,541,547,553,566,586,597,607,612,618,630,644,654,668,673,679,698,703,715,726,731,736,742,750,772,810,823,835,846,851],{"__ignoreMap":40},[81,82,85,89,93,96,100],"span",{"class":83,"line":84},"line",1,[81,86,88],{"class":87},"snl16","import",[81,90,92],{"class":91},"s95oV"," crypto ",[81,94,95],{"class":87},"from",[81,97,99],{"class":98},"sU2Wk"," 'crypto'",[81,101,102],{"class":91},";\n",[81,104,106],{"class":83,"line":105},2,[81,107,109],{"emptyLinePlaceholder":108},true,"\n",[81,111,113,116,120],{"class":83,"line":112},3,[81,114,115],{"class":87},"interface",[81,117,119],{"class":118},"svObZ"," TelegramUserData",[81,121,122],{"class":91}," {\n",[81,124,126,130,133,137],{"class":83,"line":125},4,[81,127,129],{"class":128},"s9osk","  id",[81,131,132],{"class":87},":",[81,134,136],{"class":135},"sDLfK"," number",[81,138,102],{"class":91},[81,140,142,145,147,150],{"class":83,"line":141},5,[81,143,144],{"class":128},"  first_name",[81,146,132],{"class":87},[81,148,149],{"class":135}," string",[81,151,102],{"class":91},[81,153,155,158,161,163],{"class":83,"line":154},6,[81,156,157],{"class":128},"  last_name",[81,159,160],{"class":87},"?:",[81,162,149],{"class":135},[81,164,102],{"class":91},[81,166,168,171,173,175],{"class":83,"line":167},7,[81,169,170],{"class":128},"  username",[81,172,160],{"class":87},[81,174,149],{"class":135},[81,176,102],{"class":91},[81,178,180,183,185,187],{"class":83,"line":179},8,[81,181,182],{"class":128},"  language_code",[81,184,160],{"class":87},[81,186,149],{"class":135},[81,188,102],{"class":91},[81,190,192,195,197,200],{"class":83,"line":191},9,[81,193,194],{"class":128},"  is_premium",[81,196,160],{"class":87},[81,198,199],{"class":135}," boolean",[81,201,102],{"class":91},[81,203,205],{"class":83,"line":204},10,[81,206,207],{"class":91},"}\n",[81,209,211],{"class":83,"line":210},11,[81,212,109],{"emptyLinePlaceholder":108},[81,214,216,218,221],{"class":83,"line":215},12,[81,217,115],{"class":87},[81,219,220],{"class":118}," ValidationResult",[81,222,122],{"class":91},[81,224,226,229,231,233],{"class":83,"line":225},13,[81,227,228],{"class":128},"  isValid",[81,230,132],{"class":87},[81,232,199],{"class":135},[81,234,102],{"class":91},[81,236,238,241,243,245],{"class":83,"line":237},14,[81,239,240],{"class":128},"  user",[81,242,160],{"class":87},[81,244,119],{"class":118},[81,246,102],{"class":91},[81,248,250],{"class":83,"line":249},15,[81,251,207],{"class":91},[81,253,255],{"class":83,"line":254},16,[81,256,109],{"emptyLinePlaceholder":108},[81,258,260,263,266,269,272,275,277,279,282,285,287,289,292,294,296],{"class":83,"line":259},17,[81,261,262],{"class":87},"export",[81,264,265],{"class":87}," function",[81,267,268],{"class":118}," verifyTelegramInitData",[81,270,271],{"class":91},"(",[81,273,274],{"class":128},"rawQueryString",[81,276,132],{"class":87},[81,278,149],{"class":135},[81,280,281],{"class":91},", ",[81,283,284],{"class":128},"botToken",[81,286,132],{"class":87},[81,288,149],{"class":135},[81,290,291],{"class":91},")",[81,293,132],{"class":87},[81,295,220],{"class":118},[81,297,122],{"class":91},[81,299,301,304,307,310,313,316],{"class":83,"line":300},18,[81,302,303],{"class":87},"  const",[81,305,306],{"class":135}," urlParams",[81,308,309],{"class":87}," =",[81,311,312],{"class":87}," new",[81,314,315],{"class":118}," URLSearchParams",[81,317,318],{"class":91},"(rawQueryString);\n",[81,320,322,324,327,329,332,335,337,340],{"class":83,"line":321},19,[81,323,303],{"class":87},[81,325,326],{"class":135}," hash",[81,328,309],{"class":87},[81,330,331],{"class":91}," urlParams.",[81,333,334],{"class":118},"get",[81,336,271],{"class":91},[81,338,339],{"class":98},"'hash'",[81,341,342],{"class":91},");\n",[81,344,346],{"class":83,"line":345},20,[81,347,348],{"class":91},"  \n",[81,350,352,355,358,361],{"class":83,"line":351},21,[81,353,354],{"class":87},"  if",[81,356,357],{"class":91}," (",[81,359,360],{"class":87},"!",[81,362,363],{"class":91},"hash) {\n",[81,365,367,370,373,376],{"class":83,"line":366},22,[81,368,369],{"class":87},"    return",[81,371,372],{"class":91}," { isValid: ",[81,374,375],{"class":135},"false",[81,377,378],{"class":91}," };\n",[81,380,382],{"class":83,"line":381},23,[81,383,384],{"class":91},"  }\n",[81,386,388],{"class":83,"line":387},24,[81,389,109],{"emptyLinePlaceholder":108},[81,391,393],{"class":83,"line":392},25,[81,394,396],{"class":395},"sAwPA","  \u002F\u002F 1. Sort all incoming parameters alphabetically, excluding the hash itself\n",[81,398,400,402,405,407,410,412,415,418,421,424,426,429,432,435,438,441,444,447],{"class":83,"line":399},26,[81,401,303],{"class":87},[81,403,404],{"class":135}," keys",[81,406,309],{"class":87},[81,408,409],{"class":91}," Array.",[81,411,95],{"class":118},[81,413,414],{"class":91},"(urlParams.",[81,416,417],{"class":118},"keys",[81,419,420],{"class":91},"()).",[81,422,423],{"class":118},"filter",[81,425,271],{"class":91},[81,427,428],{"class":128},"key",[81,430,431],{"class":87}," =>",[81,433,434],{"class":91}," key ",[81,436,437],{"class":87},"!==",[81,439,440],{"class":98}," 'hash'",[81,442,443],{"class":91},").",[81,445,446],{"class":118},"sort",[81,448,449],{"class":91},"();\n",[81,451,453],{"class":83,"line":452},27,[81,454,348],{"class":91},[81,456,458],{"class":83,"line":457},28,[81,459,460],{"class":395},"  \u002F\u002F 2. Re-create the verification data-check string\n",[81,462,464,466,469,471],{"class":83,"line":463},29,[81,465,303],{"class":87},[81,467,468],{"class":135}," dataCheckString",[81,470,309],{"class":87},[81,472,473],{"class":91}," keys\n",[81,475,477,480,483,485,487,489,492,494,497,500,502,504,506,508,510,513],{"class":83,"line":476},30,[81,478,479],{"class":91},"    .",[81,481,482],{"class":118},"map",[81,484,271],{"class":91},[81,486,428],{"class":128},[81,488,431],{"class":87},[81,490,491],{"class":98}," `${",[81,493,428],{"class":91},[81,495,496],{"class":98},"}=${",[81,498,499],{"class":91},"urlParams",[81,501,62],{"class":98},[81,503,334],{"class":118},[81,505,271],{"class":98},[81,507,428],{"class":91},[81,509,291],{"class":98},[81,511,512],{"class":98},"}`",[81,514,515],{"class":91},")\n",[81,517,519,521,524,526,529,532,534],{"class":83,"line":518},31,[81,520,479],{"class":91},[81,522,523],{"class":118},"join",[81,525,271],{"class":91},[81,527,528],{"class":98},"'",[81,530,531],{"class":135},"\\n",[81,533,528],{"class":98},[81,535,342],{"class":91},[81,537,539],{"class":83,"line":538},32,[81,540,109],{"emptyLinePlaceholder":108},[81,542,544],{"class":83,"line":543},33,[81,545,546],{"class":395},"  \u002F\u002F 3. Generate the secret cryptographic key\n",[81,548,550],{"class":83,"line":549},34,[81,551,552],{"class":395},"  \u002F\u002F We use the constant string \"WebAppData\" to sign the bot token first\n",[81,554,556,558,561,563],{"class":83,"line":555},35,[81,557,303],{"class":87},[81,559,560],{"class":135}," secretKey",[81,562,309],{"class":87},[81,564,565],{"class":91}," crypto\n",[81,567,569,571,574,576,579,581,584],{"class":83,"line":568},36,[81,570,479],{"class":91},[81,572,573],{"class":118},"createHmac",[81,575,271],{"class":91},[81,577,578],{"class":98},"'sha256'",[81,580,281],{"class":91},[81,582,583],{"class":98},"'WebAppData'",[81,585,515],{"class":91},[81,587,589,591,594],{"class":83,"line":588},37,[81,590,479],{"class":91},[81,592,593],{"class":118},"update",[81,595,596],{"class":91},"(botToken)\n",[81,598,600,602,605],{"class":83,"line":599},38,[81,601,479],{"class":91},[81,603,604],{"class":118},"digest",[81,606,449],{"class":91},[81,608,610],{"class":83,"line":609},39,[81,611,109],{"emptyLinePlaceholder":108},[81,613,615],{"class":83,"line":614},40,[81,616,617],{"class":395},"  \u002F\u002F 4. Calculate the expected hash of the sorted string\n",[81,619,621,623,626,628],{"class":83,"line":620},41,[81,622,303],{"class":87},[81,624,625],{"class":135}," computedHash",[81,627,309],{"class":87},[81,629,565],{"class":91},[81,631,633,635,637,639,641],{"class":83,"line":632},42,[81,634,479],{"class":91},[81,636,573],{"class":118},[81,638,271],{"class":91},[81,640,578],{"class":98},[81,642,643],{"class":91},", secretKey)\n",[81,645,647,649,651],{"class":83,"line":646},43,[81,648,479],{"class":91},[81,650,593],{"class":118},[81,652,653],{"class":91},"(dataCheckString)\n",[81,655,657,659,661,663,666],{"class":83,"line":656},44,[81,658,479],{"class":91},[81,660,604],{"class":118},[81,662,271],{"class":91},[81,664,665],{"class":98},"'hex'",[81,667,342],{"class":91},[81,669,671],{"class":83,"line":670},45,[81,672,109],{"emptyLinePlaceholder":108},[81,674,676],{"class":83,"line":675},46,[81,677,678],{"class":395},"  \u002F\u002F 5. Compare computed signature with the signature sent by the client\n",[81,680,682,684,687,689,692,695],{"class":83,"line":681},47,[81,683,303],{"class":87},[81,685,686],{"class":135}," isValid",[81,688,309],{"class":87},[81,690,691],{"class":91}," computedHash ",[81,693,694],{"class":87},"===",[81,696,697],{"class":91}," hash;\n",[81,699,701],{"class":83,"line":700},48,[81,702,109],{"emptyLinePlaceholder":108},[81,704,706,708,710,712],{"class":83,"line":705},49,[81,707,354],{"class":87},[81,709,357],{"class":91},[81,711,360],{"class":87},[81,713,714],{"class":91},"isValid) {\n",[81,716,718,720,722,724],{"class":83,"line":717},50,[81,719,369],{"class":87},[81,721,372],{"class":91},[81,723,375],{"class":135},[81,725,378],{"class":91},[81,727,729],{"class":83,"line":728},51,[81,730,384],{"class":91},[81,732,734],{"class":83,"line":733},52,[81,735,109],{"emptyLinePlaceholder":108},[81,737,739],{"class":83,"line":738},53,[81,740,741],{"class":395},"  \u002F\u002F Parse user data object if validation succeeded\n",[81,743,745,748],{"class":83,"line":744},54,[81,746,747],{"class":87},"  try",[81,749,122],{"class":91},[81,751,753,756,759,761,763,765,767,770],{"class":83,"line":752},55,[81,754,755],{"class":87},"    const",[81,757,758],{"class":135}," userRaw",[81,760,309],{"class":87},[81,762,331],{"class":91},[81,764,334],{"class":118},[81,766,271],{"class":91},[81,768,769],{"class":98},"'user'",[81,771,342],{"class":91},[81,773,775,777,780,782,784,786,789,792,795,797,800,803,805,808],{"class":83,"line":774},56,[81,776,755],{"class":87},[81,778,779],{"class":135}," user",[81,781,132],{"class":87},[81,783,119],{"class":118},[81,785,309],{"class":87},[81,787,788],{"class":91}," userRaw ",[81,790,791],{"class":87},"?",[81,793,794],{"class":135}," JSON",[81,796,62],{"class":91},[81,798,799],{"class":118},"parse",[81,801,802],{"class":91},"(userRaw) ",[81,804,132],{"class":87},[81,806,807],{"class":135}," undefined",[81,809,102],{"class":91},[81,811,813,815,817,820],{"class":83,"line":812},57,[81,814,369],{"class":87},[81,816,372],{"class":91},[81,818,819],{"class":135},"true",[81,821,822],{"class":91},", user };\n",[81,824,826,829,832],{"class":83,"line":825},58,[81,827,828],{"class":91},"  } ",[81,830,831],{"class":87},"catch",[81,833,834],{"class":91}," (error) {\n",[81,836,838,840,842,844],{"class":83,"line":837},59,[81,839,369],{"class":87},[81,841,372],{"class":91},[81,843,819],{"class":135},[81,845,378],{"class":91},[81,847,849],{"class":83,"line":848},60,[81,850,384],{"class":91},[81,852,854],{"class":83,"line":853},61,[81,855,207],{"class":91},[26,857,859],{"id":858},"_2-frontend-integration-theme-synchronization","2. Frontend Integration & Theme Synchronization",[11,861,862],{},"To deliver a premium UI\u002FUX, your Mini App should visually blend with the Telegram client’s dark\u002Flight settings. You can access the styles and control client-side behaviors using the official Telegram WebApp JS library.",[864,865,867],"h3",{"id":866},"step-1-include-the-script-in-nuxt-3-html","Step 1: Include the Script in Nuxt 3 \u002F HTML",[11,869,870,871,874],{},"Add the official script to your page header or use the ",[42,872,873],{},"useHead"," composable in Nuxt:",[34,876,878],{"className":75,"code":877,"language":77,"meta":40,"style":40},"\u002F\u002F app.vue or page layout\nuseHead({\n  script: [\n    { src: 'https:\u002F\u002Ftelegram.org\u002Fjs\u002Ftelegram-web-app.js', defer: true }\n  ]\n})\n",[42,879,880,885,892,897,913,918],{"__ignoreMap":40},[81,881,882],{"class":83,"line":84},[81,883,884],{"class":395},"\u002F\u002F app.vue or page layout\n",[81,886,887,889],{"class":83,"line":105},[81,888,873],{"class":118},[81,890,891],{"class":91},"({\n",[81,893,894],{"class":83,"line":112},[81,895,896],{"class":91},"  script: [\n",[81,898,899,902,905,908,910],{"class":83,"line":125},[81,900,901],{"class":91},"    { src: ",[81,903,904],{"class":98},"'https:\u002F\u002Ftelegram.org\u002Fjs\u002Ftelegram-web-app.js'",[81,906,907],{"class":91},", defer: ",[81,909,819],{"class":135},[81,911,912],{"class":91}," }\n",[81,914,915],{"class":83,"line":141},[81,916,917],{"class":91},"  ]\n",[81,919,920],{"class":83,"line":154},[81,921,922],{"class":91},"})\n",[864,924,926],{"id":925},"step-2-access-the-webapp-bridge-in-vue","Step 2: Access the WebApp Bridge in Vue",[11,928,929],{},"Create a Vue composable to access and synchronize Telegram styles:",[34,931,933],{"className":75,"code":932,"language":77,"meta":40,"style":40},"\u002F\u002F composables\u002FuseTelegram.ts\nimport { ref, onMounted } from 'vue';\n\nexport function useTelegram() {\n  const isReady = ref(false);\n  const user = ref\u003Cany>(null);\n\n  onMounted(() => {\n    const tg = (window as any).Telegram?.WebApp;\n    if (tg) {\n      tg.ready();\n      tg.expand(); \u002F\u002F Request the container to fill maximum vertical space\n      \n      user.value = tg.initDataUnsafe?.user;\n      isReady.value = true;\n\n      \u002F\u002F Apply Telegram theme colors to CSS custom properties\n      const root = document.documentElement;\n      root.style.setProperty('--color-tg-bg', tg.themeParams.bg_color);\n      root.style.setProperty('--color-tg-text', tg.themeParams.text_color);\n      root.style.setProperty('--color-tg-button', tg.themeParams.button_color);\n      root.style.setProperty('--color-tg-button-text', tg.themeParams.button_text_color);\n    }\n  });\n\n  return { isReady, user };\n}\n",[42,934,935,940,954,958,970,988,1012,1016,1029,1050,1058,1068,1081,1086,1097,1109,1113,1118,1131,1147,1161,1175,1189,1194,1199,1203,1211],{"__ignoreMap":40},[81,936,937],{"class":83,"line":84},[81,938,939],{"class":395},"\u002F\u002F composables\u002FuseTelegram.ts\n",[81,941,942,944,947,949,952],{"class":83,"line":105},[81,943,88],{"class":87},[81,945,946],{"class":91}," { ref, onMounted } ",[81,948,95],{"class":87},[81,950,951],{"class":98}," 'vue'",[81,953,102],{"class":91},[81,955,956],{"class":83,"line":112},[81,957,109],{"emptyLinePlaceholder":108},[81,959,960,962,964,967],{"class":83,"line":125},[81,961,262],{"class":87},[81,963,265],{"class":87},[81,965,966],{"class":118}," useTelegram",[81,968,969],{"class":91},"() {\n",[81,971,972,974,977,979,982,984,986],{"class":83,"line":141},[81,973,303],{"class":87},[81,975,976],{"class":135}," isReady",[81,978,309],{"class":87},[81,980,981],{"class":118}," ref",[81,983,271],{"class":91},[81,985,375],{"class":135},[81,987,342],{"class":91},[81,989,990,992,994,996,998,1001,1004,1007,1010],{"class":83,"line":154},[81,991,303],{"class":87},[81,993,779],{"class":135},[81,995,309],{"class":87},[81,997,981],{"class":118},[81,999,1000],{"class":91},"\u003C",[81,1002,1003],{"class":135},"any",[81,1005,1006],{"class":91},">(",[81,1008,1009],{"class":135},"null",[81,1011,342],{"class":91},[81,1013,1014],{"class":83,"line":167},[81,1015,109],{"emptyLinePlaceholder":108},[81,1017,1018,1021,1024,1027],{"class":83,"line":179},[81,1019,1020],{"class":118},"  onMounted",[81,1022,1023],{"class":91},"(() ",[81,1025,1026],{"class":87},"=>",[81,1028,122],{"class":91},[81,1030,1031,1033,1036,1038,1041,1044,1047],{"class":83,"line":191},[81,1032,755],{"class":87},[81,1034,1035],{"class":135}," tg",[81,1037,309],{"class":87},[81,1039,1040],{"class":91}," (window ",[81,1042,1043],{"class":87},"as",[81,1045,1046],{"class":135}," any",[81,1048,1049],{"class":91},").Telegram?.WebApp;\n",[81,1051,1052,1055],{"class":83,"line":204},[81,1053,1054],{"class":87},"    if",[81,1056,1057],{"class":91}," (tg) {\n",[81,1059,1060,1063,1066],{"class":83,"line":210},[81,1061,1062],{"class":91},"      tg.",[81,1064,1065],{"class":118},"ready",[81,1067,449],{"class":91},[81,1069,1070,1072,1075,1078],{"class":83,"line":215},[81,1071,1062],{"class":91},[81,1073,1074],{"class":118},"expand",[81,1076,1077],{"class":91},"(); ",[81,1079,1080],{"class":395},"\u002F\u002F Request the container to fill maximum vertical space\n",[81,1082,1083],{"class":83,"line":225},[81,1084,1085],{"class":91},"      \n",[81,1087,1088,1091,1094],{"class":83,"line":237},[81,1089,1090],{"class":91},"      user.value ",[81,1092,1093],{"class":87},"=",[81,1095,1096],{"class":91}," tg.initDataUnsafe?.user;\n",[81,1098,1099,1102,1104,1107],{"class":83,"line":249},[81,1100,1101],{"class":91},"      isReady.value ",[81,1103,1093],{"class":87},[81,1105,1106],{"class":135}," true",[81,1108,102],{"class":91},[81,1110,1111],{"class":83,"line":254},[81,1112,109],{"emptyLinePlaceholder":108},[81,1114,1115],{"class":83,"line":259},[81,1116,1117],{"class":395},"      \u002F\u002F Apply Telegram theme colors to CSS custom properties\n",[81,1119,1120,1123,1126,1128],{"class":83,"line":300},[81,1121,1122],{"class":87},"      const",[81,1124,1125],{"class":135}," root",[81,1127,309],{"class":87},[81,1129,1130],{"class":91}," document.documentElement;\n",[81,1132,1133,1136,1139,1141,1144],{"class":83,"line":321},[81,1134,1135],{"class":91},"      root.style.",[81,1137,1138],{"class":118},"setProperty",[81,1140,271],{"class":91},[81,1142,1143],{"class":98},"'--color-tg-bg'",[81,1145,1146],{"class":91},", tg.themeParams.bg_color);\n",[81,1148,1149,1151,1153,1155,1158],{"class":83,"line":345},[81,1150,1135],{"class":91},[81,1152,1138],{"class":118},[81,1154,271],{"class":91},[81,1156,1157],{"class":98},"'--color-tg-text'",[81,1159,1160],{"class":91},", tg.themeParams.text_color);\n",[81,1162,1163,1165,1167,1169,1172],{"class":83,"line":351},[81,1164,1135],{"class":91},[81,1166,1138],{"class":118},[81,1168,271],{"class":91},[81,1170,1171],{"class":98},"'--color-tg-button'",[81,1173,1174],{"class":91},", tg.themeParams.button_color);\n",[81,1176,1177,1179,1181,1183,1186],{"class":83,"line":366},[81,1178,1135],{"class":91},[81,1180,1138],{"class":118},[81,1182,271],{"class":91},[81,1184,1185],{"class":98},"'--color-tg-button-text'",[81,1187,1188],{"class":91},", tg.themeParams.button_text_color);\n",[81,1190,1191],{"class":83,"line":381},[81,1192,1193],{"class":91},"    }\n",[81,1195,1196],{"class":83,"line":387},[81,1197,1198],{"class":91},"  });\n",[81,1200,1201],{"class":83,"line":392},[81,1202,109],{"emptyLinePlaceholder":108},[81,1204,1205,1208],{"class":83,"line":399},[81,1206,1207],{"class":87},"  return",[81,1209,1210],{"class":91}," { isReady, user };\n",[81,1212,1213],{"class":83,"line":452},[81,1214,207],{"class":91},[26,1216,1218],{"id":1217},"_3-monetization-payments-via-telegram-stars","3. Monetization: Payments via Telegram Stars",[11,1220,1221,1222,357,1225,1228],{},"When operating inside Telegram, all digital services or content purchases must comply with Apple App Store and Google Play policies. Telegram enforces this by requiring the use of ",[15,1223,1224],{},"Telegram Stars",[42,1226,1227],{},"XTR",") for digital goods.",[11,1230,1231],{},"The flow for receiving Stars payments:",[1233,1234,1235,1242,1254,1260],"ol",{},[1236,1237,1238,1241],"li",{},[15,1239,1240],{},"Request Invoice",": The TWA requests the backend to generate an invoice.",[1236,1243,1244,1247,1248,1251,1252,62],{},[15,1245,1246],{},"Bot Sends Invoice",": The backend calls Bot API ",[42,1249,1250],{},"sendInvoice"," using the currency ",[42,1253,1227],{},[1236,1255,1256,1259],{},[15,1257,1258],{},"Client Checkout",": The Telegram client opens a native overlay allowing the user to pay using Stars purchased in-app.",[1236,1261,1262,1265,1266,1269],{},[15,1263,1264],{},"Verification",": Telegram sends a webhook ",[42,1267,1268],{},"successful_payment"," to your bot, which credits the user's account in your DB.",[11,1271,1272,1273,1278,1279,62],{},"In my Telegram bot hosting platform, ",[1274,1275,1277],"a",{"href":1276},"\u002Fprojects\u002Ftelego","TeleGo.io",", we provide exactly these billing options, allowing users to spin up their own TWAs and receive Stars instantly. Additionally, we support hybrid human-AI helpdesks, which you can learn to build in my guide on ",[1274,1280,1282],{"href":1281},"\u002Fblog\u002Ftelegram-bot-ai-rag-support","RAG AI Customer Support Bots",[11,1284,1285,1286,1290],{},"For physical goods and consulting services, traditional gateways can be used. Read my ",[1274,1287,1289],{"href":1288},"\u002Fblog\u002Fsaas-stripe-billing-integration","SaaS Stripe Billing Integration guide"," for Express\u002FTypeScript templates.",[26,1292,1294],{"id":1293},"sources-and-documentation","Sources and documentation",[1296,1297,1298,1307],"ul",{},[1236,1299,1300,1306],{},[1274,1301,1305],{"href":1302,"rel":1303},"https:\u002F\u002Fcore.telegram.org\u002Fbots\u002Fwebapps",[1304],"nofollow","Telegram Mini Apps"," — the official WebApp SDK and initData documentation",[1236,1308,1309,1314],{},[1274,1310,1313],{"href":1311,"rel":1312},"https:\u002F\u002Fcore.telegram.org\u002Fbots\u002Fpayments",[1304],"Bot Payments API"," — invoices, Telegram Stars, and payment webhooks",[11,1316,1317],{},"By combining native web frameworks with the Telegram WebApp API, developers can ship complex SaaS platforms and digital products directly into active messaging channels.",[11,1319,1320,1321,1325,1326,1330],{},"If you are planning to build a high-performance Telegram Mini App with secure billing integrations and dynamic frontend design, check out my ",[1274,1322,1324],{"href":1323},"\u002Ftelegram-bots","Telegram Bot Development Service"," or request a ",[1274,1327,1329],{"href":1328},"\u002Fconsultations","Technical Architecture Consultation"," to get a production blueprint.",[1332,1333,1334],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":40,"searchDepth":105,"depth":105,"links":1336},[1337,1338,1340,1344,1345],{"id":28,"depth":105,"text":29},{"id":46,"depth":105,"text":1339},"1. Security First: Validating the initData Payload",{"id":858,"depth":105,"text":859,"children":1341},[1342,1343],{"id":866,"depth":112,"text":867},{"id":925,"depth":112,"text":926},{"id":1217,"depth":105,"text":1218},{"id":1293,"depth":105,"text":1294},"2026-07-05","A comprehensive developer's guide to using Telegram Mini Apps as a dynamic web frontend, implementing secure hash validation, and processing in-app purchases.","md",[1350,1353,1356],{"q":1351,"a":1352},"How is a Telegram Mini App different from a regular bot?","A bot communicates through messages and buttons, while a Mini App opens a full web interface inside Telegram: dashboards, catalogs, forms, charts. It's an SPA running in a WebView with its own frontend, and authorization happens automatically through the Telegram account.",{"q":1354,"a":1355},"How do I verify that user data in a Mini App isn't forged?","Through cryptographic initData validation: Telegram signs the user data with an HMAC-SHA256 signature derived from the bot token. The backend must recompute and compare the signature on every request — without it, anyone can impersonate any user.",{"q":1357,"a":1358},"When are Telegram Stars mandatory, and when can I use Stripe?","Digital goods and subscriptions inside a Mini App must be paid with Telegram Stars per Apple and Google policies. Physical goods, services, and consulting can be sold through classic gateways like Stripe.","\u002Fimages\u002Fblog\u002Fblog_telegram_twa.jpg","en",{},"\u002Fblog\u002Fen\u002Ftelegram-web-apps-sells",{"title":6,"description":1347},"blog\u002Fen\u002Ftelegram-web-apps-sells",[1366,1367,1368,1369,1370],"Telegram","Mini Apps","SaaS","Node.js","Frontend","4blcVbDR853MsonsChj72pfs0C4mqm-aL83XkBOZ0io",{"id":1373,"title":1374,"body":1375,"date":2081,"description":2082,"extension":1348,"faq":2083,"image":2093,"lang":1360,"meta":2094,"navigation":108,"path":2095,"published":108,"readTime":215,"seo":2096,"stem":2097,"tags":2098,"updated":1346,"__hash__":2103},"blog\u002Fblog\u002Fen\u002Fsaas-multitenancy-databases.md","SaaS Database Architecture: Single-Tenant vs. Multi-Tenant Patterns",{"type":8,"value":1376,"toc":2070},[1377,1380,1383,1387,1393,1397,1400,1546,1550,1557,1564,1568,1575,1680,1684,1690,1710,1714,1717,1983,1987,2013,2021,2023,2049,2056,2067],[11,1378,1379],{},"Choosing the database architecture for your B2B SaaS is one of the most critical decisions you will make. It directly impacts your infrastructure costs, development velocity, database migration complexity, and compliance with data security standards (such as GDPR, HIPAA, or SOC2).",[11,1381,1382],{},"A mistake here is incredibly expensive to rectify later. In this technical guide, we will analyze the three primary multi-tenant database patterns, map their trade-offs in detail, and write a complete, secure implementation using PostgreSQL Row-Level Security (RLS).",[26,1384,1386],{"id":1385},"the-three-multi-tenant-paradigms","The Three Multi-Tenant Paradigms",[34,1388,1391],{"className":1389,"code":1390,"language":39,"meta":40},[37],"+-----------------------+   +-----------------------+   +-----------------------+\n|  Pattern A: Separate  |   |  Pattern B: Separate  |   |   Pattern C: Shared   |\n|       Databases       |   |        Schemas        |   |    Database & Tables  |\n+-----------------------+   +-----------------------+   +-----------------------+\n|  [DB 1]      [DB 2]   |   |  [Database]           |   |  [Database]           |\n|  Tenant A    Tenant B |   |   ├── Schema Tenant A |   |   └── [Table]         |\n|                       |   |   └── Schema Tenant B |   |        ├── Row Tenant A|\n| (Max Isolation\u002FCost)  |   | (Medium Isolation)    |   |        └── Row Tenant B|\n+-----------------------+   +-----------------------+   +-----------------------+\n",[42,1392,1390],{"__ignoreMap":40},[26,1394,1396],{"id":1395},"_1-deep-dive-comparison","1. Deep Dive Comparison",[11,1398,1399],{},"To make an informed decision, let's contrast the three patterns across operational metrics:",[1401,1402,1403,1423],"table",{},[1404,1405,1406],"thead",{},[1407,1408,1409,1414,1417,1420],"tr",{},[1410,1411,1413],"th",{"align":1412},"left","Metric",[1410,1415,1416],{"align":1412},"Pattern A: Separate DBs",[1410,1418,1419],{"align":1412},"Pattern B: Separate Schemas",[1410,1421,1422],{"align":1412},"Pattern C: Shared Table (RLS)",[1424,1425,1426,1452,1475,1499,1523],"tbody",{},[1407,1427,1428,1434,1440,1446],{},[1429,1430,1431],"td",{"align":1412},[15,1432,1433],{},"Data Isolation",[1429,1435,1436,1439],{"align":1412},[15,1437,1438],{},"Highest",": Zero risk of cross-tenant leakage.",[1429,1441,1442,1445],{"align":1412},[15,1443,1444],{},"High",": Logic-level namespace isolation.",[1429,1447,1448,1451],{"align":1412},[15,1449,1450],{},"Medium",": Relies on SQL query filters or database policies.",[1407,1453,1454,1459,1464,1469],{},[1429,1455,1456],{"align":1412},[15,1457,1458],{},"Infrastructure Cost",[1429,1460,1461,1463],{"align":1412},[15,1462,1438],{},": Resource overhead for running multiple DB servers or pools.",[1429,1465,1466,1468],{"align":1412},[15,1467,1450],{},": Shared hardware, catalog memory limits.",[1429,1470,1471,1474],{"align":1412},[15,1472,1473],{},"Lowest",": Maximum utilization of database resources.",[1407,1476,1477,1482,1488,1493],{},[1429,1478,1479],{"align":1412},[15,1480,1481],{},"Migrations Complexity",[1429,1483,1484,1487],{"align":1412},[15,1485,1486],{},"Very High",": Need to run migration scripts on hundreds of DBs in sequence.",[1429,1489,1490,1492],{"align":1412},[15,1491,1444],{},": Iterating updates through hundreds of schemas.",[1429,1494,1495,1498],{"align":1412},[15,1496,1497],{},"Simple",": Single standard migration run.",[1407,1500,1501,1506,1512,1517],{},[1429,1502,1503],{"align":1412},[15,1504,1505],{},"Connection Pooling",[1429,1507,1508,1511],{"align":1412},[15,1509,1510],{},"Hard",": Separate connection pool required per database.",[1429,1513,1514,1516],{"align":1412},[15,1515,1450],{},": Can use one pool but requires schema-switching commands.",[1429,1518,1519,1522],{"align":1412},[15,1520,1521],{},"Easiest",": Single connection pool shared by all tenants.",[1407,1524,1525,1530,1536,1541],{},[1429,1526,1527],{"align":1412},[15,1528,1529],{},"Backup & Restore",[1429,1531,1532,1535],{"align":1412},[15,1533,1534],{},"Perfect",": Can easily back up or restore a single customer.",[1429,1537,1538,1540],{"align":1412},[15,1539,1450],{},": Requires selective pg_dump\u002Fpg_restore of schemas.",[1429,1542,1543,1545],{"align":1412},[15,1544,1510],{},": Restoring one client requires filtering logs or data exports.",[26,1547,1549],{"id":1548},"_2-implementing-row-level-security-rls-in-postgresql","2. Implementing Row-Level Security (RLS) in PostgreSQL",[11,1551,1552,1553,1556],{},"If you choose ",[15,1554,1555],{},"Pattern C"," (Shared Database & Tables) due to its cost efficiency and ease of maintenance, you must guarantee that users cannot accidentally or maliciously access another customer's data.",[11,1558,1559,1560,1563],{},"PostgreSQL ",[15,1561,1562],{},"Row-Level Security (RLS)"," handles this by applying filter policies directly inside the SQL engine.",[864,1565,1567],{"id":1566},"step-1-create-tables-and-enable-rls","Step 1: Create Tables and Enable RLS",[11,1569,1570,1571,1574],{},"We will design a simple CRM table for storing leads. We explicitly enable RLS using the ",[42,1572,1573],{},"ALTER TABLE"," statement.",[34,1576,1580],{"className":1577,"code":1578,"language":1579,"meta":40,"style":40},"language-sql shiki shiki-themes github-dark","-- Create tenants table\nCREATE TABLE tenants (\n  id SERIAL PRIMARY KEY,\n  company_name VARCHAR(255) NOT NULL,\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Create projects table with tenant mapping\nCREATE TABLE projects (\n  id SERIAL PRIMARY KEY,\n  tenant_id INT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,\n  name VARCHAR(255) NOT NULL,\n  status VARCHAR(50) DEFAULT 'draft',\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Index the tenant_id column (Crucial for query performance across multiple tenants)\nCREATE INDEX idx_projects_tenant ON projects(tenant_id);\n\n-- Enable RLS for the projects table\nALTER TABLE projects ENABLE ROW LEVEL SECURITY;\n","sql",[42,1581,1582,1587,1592,1597,1602,1607,1611,1615,1620,1625,1629,1634,1639,1644,1648,1652,1656,1661,1666,1670,1675],{"__ignoreMap":40},[81,1583,1584],{"class":83,"line":84},[81,1585,1586],{},"-- Create tenants table\n",[81,1588,1589],{"class":83,"line":105},[81,1590,1591],{},"CREATE TABLE tenants (\n",[81,1593,1594],{"class":83,"line":112},[81,1595,1596],{},"  id SERIAL PRIMARY KEY,\n",[81,1598,1599],{"class":83,"line":125},[81,1600,1601],{},"  company_name VARCHAR(255) NOT NULL,\n",[81,1603,1604],{"class":83,"line":141},[81,1605,1606],{},"  created_at TIMESTAMP DEFAULT NOW()\n",[81,1608,1609],{"class":83,"line":154},[81,1610,342],{},[81,1612,1613],{"class":83,"line":167},[81,1614,109],{"emptyLinePlaceholder":108},[81,1616,1617],{"class":83,"line":179},[81,1618,1619],{},"-- Create projects table with tenant mapping\n",[81,1621,1622],{"class":83,"line":191},[81,1623,1624],{},"CREATE TABLE projects (\n",[81,1626,1627],{"class":83,"line":204},[81,1628,1596],{},[81,1630,1631],{"class":83,"line":210},[81,1632,1633],{},"  tenant_id INT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,\n",[81,1635,1636],{"class":83,"line":215},[81,1637,1638],{},"  name VARCHAR(255) NOT NULL,\n",[81,1640,1641],{"class":83,"line":225},[81,1642,1643],{},"  status VARCHAR(50) DEFAULT 'draft',\n",[81,1645,1646],{"class":83,"line":237},[81,1647,1606],{},[81,1649,1650],{"class":83,"line":249},[81,1651,342],{},[81,1653,1654],{"class":83,"line":254},[81,1655,109],{"emptyLinePlaceholder":108},[81,1657,1658],{"class":83,"line":259},[81,1659,1660],{},"-- Index the tenant_id column (Crucial for query performance across multiple tenants)\n",[81,1662,1663],{"class":83,"line":300},[81,1664,1665],{},"CREATE INDEX idx_projects_tenant ON projects(tenant_id);\n",[81,1667,1668],{"class":83,"line":321},[81,1669,109],{"emptyLinePlaceholder":108},[81,1671,1672],{"class":83,"line":345},[81,1673,1674],{},"-- Enable RLS for the projects table\n",[81,1676,1677],{"class":83,"line":351},[81,1678,1679],{},"ALTER TABLE projects ENABLE ROW LEVEL SECURITY;\n",[864,1681,1683],{"id":1682},"step-2-define-the-security-policy","Step 2: Define the Security Policy",[11,1685,1686,1687,62],{},"We will configure PostgreSQL to restrict access based on a session configuration variable called ",[42,1688,1689],{},"app.current_tenant_id",[34,1691,1693],{"className":1577,"code":1692,"language":1579,"meta":40,"style":40},"CREATE POLICY tenant_isolation_policy ON projects\n  AS ASYMMETRIC\n  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::integer);\n",[42,1694,1695,1700,1705],{"__ignoreMap":40},[81,1696,1697],{"class":83,"line":84},[81,1698,1699],{},"CREATE POLICY tenant_isolation_policy ON projects\n",[81,1701,1702],{"class":83,"line":105},[81,1703,1704],{},"  AS ASYMMETRIC\n",[81,1706,1707],{"class":83,"line":112},[81,1708,1709],{},"  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::integer);\n",[864,1711,1713],{"id":1712},"step-3-accessing-data-from-the-application-layer","Step 3: Accessing Data from the Application Layer",[11,1715,1716],{},"When your backend (e.g., Node.js\u002FFastify or Nuxt server routes) connects to PostgreSQL, it runs queries inside a transaction, setting the session context variable first.",[34,1718,1720],{"className":75,"code":1719,"language":77,"meta":40,"style":40},"import { Client } from 'pg';\n\nasync function fetchTenantProjects(tenantId: number): Promise\u003Cany[]> {\n  const client = new Client({ connectionString: process.env.DATABASE_URL });\n  await client.connect();\n\n  try {\n    \u002F\u002F Start transaction\n    await client.query('BEGIN');\n\n    \u002F\u002F Set the tenant ID context variable for the duration of this transaction\n    await client.query(`SET LOCAL app.current_tenant_id = $1`, [tenantId]);\n\n    \u002F\u002F Even if we query SELECT * without a WHERE clause, RLS filters the records\n    const result = await client.query('SELECT * FROM projects');\n\n    await client.query('COMMIT');\n    return result.rows;\n  } catch (error) {\n    await client.query('ROLLBACK');\n    throw error;\n  } finally {\n    await client.end();\n  }\n}\n",[42,1721,1722,1736,1740,1773,1796,1809,1813,1819,1824,1841,1845,1850,1866,1870,1875,1898,1902,1917,1924,1932,1947,1955,1964,1975,1979],{"__ignoreMap":40},[81,1723,1724,1726,1729,1731,1734],{"class":83,"line":84},[81,1725,88],{"class":87},[81,1727,1728],{"class":91}," { Client } ",[81,1730,95],{"class":87},[81,1732,1733],{"class":98}," 'pg'",[81,1735,102],{"class":91},[81,1737,1738],{"class":83,"line":105},[81,1739,109],{"emptyLinePlaceholder":108},[81,1741,1742,1745,1747,1750,1752,1755,1757,1759,1761,1763,1766,1768,1770],{"class":83,"line":112},[81,1743,1744],{"class":87},"async",[81,1746,265],{"class":87},[81,1748,1749],{"class":118}," fetchTenantProjects",[81,1751,271],{"class":91},[81,1753,1754],{"class":128},"tenantId",[81,1756,132],{"class":87},[81,1758,136],{"class":135},[81,1760,291],{"class":91},[81,1762,132],{"class":87},[81,1764,1765],{"class":118}," Promise",[81,1767,1000],{"class":91},[81,1769,1003],{"class":135},[81,1771,1772],{"class":91},"[]> {\n",[81,1774,1775,1777,1780,1782,1784,1787,1790,1793],{"class":83,"line":125},[81,1776,303],{"class":87},[81,1778,1779],{"class":135}," client",[81,1781,309],{"class":87},[81,1783,312],{"class":87},[81,1785,1786],{"class":118}," Client",[81,1788,1789],{"class":91},"({ connectionString: process.env.",[81,1791,1792],{"class":135},"DATABASE_URL",[81,1794,1795],{"class":91}," });\n",[81,1797,1798,1801,1804,1807],{"class":83,"line":141},[81,1799,1800],{"class":87},"  await",[81,1802,1803],{"class":91}," client.",[81,1805,1806],{"class":118},"connect",[81,1808,449],{"class":91},[81,1810,1811],{"class":83,"line":154},[81,1812,109],{"emptyLinePlaceholder":108},[81,1814,1815,1817],{"class":83,"line":167},[81,1816,747],{"class":87},[81,1818,122],{"class":91},[81,1820,1821],{"class":83,"line":179},[81,1822,1823],{"class":395},"    \u002F\u002F Start transaction\n",[81,1825,1826,1829,1831,1834,1836,1839],{"class":83,"line":191},[81,1827,1828],{"class":87},"    await",[81,1830,1803],{"class":91},[81,1832,1833],{"class":118},"query",[81,1835,271],{"class":91},[81,1837,1838],{"class":98},"'BEGIN'",[81,1840,342],{"class":91},[81,1842,1843],{"class":83,"line":204},[81,1844,109],{"emptyLinePlaceholder":108},[81,1846,1847],{"class":83,"line":210},[81,1848,1849],{"class":395},"    \u002F\u002F Set the tenant ID context variable for the duration of this transaction\n",[81,1851,1852,1854,1856,1858,1860,1863],{"class":83,"line":215},[81,1853,1828],{"class":87},[81,1855,1803],{"class":91},[81,1857,1833],{"class":118},[81,1859,271],{"class":91},[81,1861,1862],{"class":98},"`SET LOCAL app.current_tenant_id = $1`",[81,1864,1865],{"class":91},", [tenantId]);\n",[81,1867,1868],{"class":83,"line":225},[81,1869,109],{"emptyLinePlaceholder":108},[81,1871,1872],{"class":83,"line":237},[81,1873,1874],{"class":395},"    \u002F\u002F Even if we query SELECT * without a WHERE clause, RLS filters the records\n",[81,1876,1877,1879,1882,1884,1887,1889,1891,1893,1896],{"class":83,"line":249},[81,1878,755],{"class":87},[81,1880,1881],{"class":135}," result",[81,1883,309],{"class":87},[81,1885,1886],{"class":87}," await",[81,1888,1803],{"class":91},[81,1890,1833],{"class":118},[81,1892,271],{"class":91},[81,1894,1895],{"class":98},"'SELECT * FROM projects'",[81,1897,342],{"class":91},[81,1899,1900],{"class":83,"line":254},[81,1901,109],{"emptyLinePlaceholder":108},[81,1903,1904,1906,1908,1910,1912,1915],{"class":83,"line":259},[81,1905,1828],{"class":87},[81,1907,1803],{"class":91},[81,1909,1833],{"class":118},[81,1911,271],{"class":91},[81,1913,1914],{"class":98},"'COMMIT'",[81,1916,342],{"class":91},[81,1918,1919,1921],{"class":83,"line":300},[81,1920,369],{"class":87},[81,1922,1923],{"class":91}," result.rows;\n",[81,1925,1926,1928,1930],{"class":83,"line":321},[81,1927,828],{"class":91},[81,1929,831],{"class":87},[81,1931,834],{"class":91},[81,1933,1934,1936,1938,1940,1942,1945],{"class":83,"line":345},[81,1935,1828],{"class":87},[81,1937,1803],{"class":91},[81,1939,1833],{"class":118},[81,1941,271],{"class":91},[81,1943,1944],{"class":98},"'ROLLBACK'",[81,1946,342],{"class":91},[81,1948,1949,1952],{"class":83,"line":351},[81,1950,1951],{"class":87},"    throw",[81,1953,1954],{"class":91}," error;\n",[81,1956,1957,1959,1962],{"class":83,"line":366},[81,1958,828],{"class":91},[81,1960,1961],{"class":87},"finally",[81,1963,122],{"class":91},[81,1965,1966,1968,1970,1973],{"class":83,"line":381},[81,1967,1828],{"class":87},[81,1969,1803],{"class":91},[81,1971,1972],{"class":118},"end",[81,1974,449],{"class":91},[81,1976,1977],{"class":83,"line":387},[81,1978,384],{"class":91},[81,1980,1981],{"class":83,"line":392},[81,1982,207],{"class":91},[26,1984,1986],{"id":1985},"_3-best-practices-for-scaling-multi-tenant-databases","3. Best Practices for Scaling Multi-Tenant Databases",[1233,1988,1989,1998,2004],{},[1236,1990,1991,1997],{},[15,1992,1993,1994],{},"Always Index ",[42,1995,1996],{},"tenant_id",": If you don't index the tenant column, every query will perform a sequential table scan, destroying database performance as the table grows.",[1236,1999,2000,2003],{},[15,2001,2002],{},"Verify policies in automated tests",": Write integration tests that attempt to fetch Tenant B's data using Tenant A's connection context, verifying that the database throws access errors or returns empty results.",[1236,2005,2006,2009,2010,2012],{},[15,2007,2008],{},"Prepare for tenant migration",": Enterprise customers may eventually ask to move to a dedicated database (Pattern A) for regulatory reasons. Design your schemas so you can easily extract records matching a specific ",[42,2011,1996],{}," and load them into a standalone instance.",[11,2014,2015,2016,2020],{},"For example, in the ",[1274,2017,2019],{"href":2018},"\u002Fprojects\u002Flingvohabit","LingvoHabit language portal",", we successfully implemented Row-Level Isolation (Pattern C) using indexed tenant schemas, keeping monthly database fees extremely low while serving thousands of registered learners.",[26,2022,1294],{"id":1293},[1296,2024,2025,2033,2041],{},[1236,2026,2027,2032],{},[1274,2028,2031],{"href":2029,"rel":2030},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-rowsecurity.html",[1304],"PostgreSQL: Row Security Policies"," — official RLS documentation",[1236,2034,2035,2040],{},[1274,2036,2039],{"href":2037,"rel":2038},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-schemas.html",[1304],"PostgreSQL: Schemas"," — logical schemas for Pattern B",[1236,2042,2043,2048],{},[1274,2044,2047],{"href":2045,"rel":2046},"https:\u002F\u002Fgdpr.eu\u002F",[1304],"GDPR.eu"," — isolation and data-portability requirements for personal data",[11,2050,2051,2052,62],{},"Choosing the right multi-tenant paradigm involves balancing your startup's budget, resources, and regulatory requirements. RLS offers an elegant and secure way to build a cost-effective, scalable B2B SaaS without compromising data safety. To see how this database setup ties into a full development lifecycle, explore my guide on ",[1274,2053,2055],{"href":2054},"\u002Fblog\u002Fb2b-saas-mvp-30-days","Building B2B SaaS MVPs in 30 Days",[11,2057,2058,2059,2063,2064,2066],{},"If you are designing the data layer for a multi-tenant application and want an expert architecture review to ensure it handles growth safely, explore my ",[1274,2060,2062],{"href":2061},"\u002Fsaas-development","SaaS Development Service"," or book a ",[1274,2065,1329],{"href":1328}," to get a production plan.",[1332,2068,2069],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":40,"searchDepth":105,"depth":105,"links":2071},[2072,2073,2074,2079,2080],{"id":1385,"depth":105,"text":1386},{"id":1395,"depth":105,"text":1396},{"id":1548,"depth":105,"text":1549,"children":2075},[2076,2077,2078],{"id":1566,"depth":112,"text":1567},{"id":1682,"depth":112,"text":1683},{"id":1712,"depth":112,"text":1713},{"id":1985,"depth":105,"text":1986},{"id":1293,"depth":105,"text":1294},"2026-06-11","A comprehensive deep dive into designing secure, scalable multi-tenant database systems for B2B SaaS. We compare shared tables, PostgreSQL schemas, and independent databases.",[2084,2087,2090],{"q":2085,"a":2086},"Which multi-tenancy pattern should a SaaS start with?","For most B2B startups — a shared database with Row-Level Security (Pattern C): minimal server costs, simple migrations, and sufficient isolation when policies are written correctly. Dedicated databases are justified only by strict regulatory requirements.",{"q":2088,"a":2089},"How safe is isolation via PostgreSQL RLS?","With RLS policies enabled, the database itself filters rows by tenant_id at the engine level — even a buggy application query won't return another tenant's data. The mandatory companion: integration tests that try to read one tenant's data through another tenant's session.",{"q":2091,"a":2092},"What if a large customer demands a dedicated database?","Design for export upfront: if every table is linked through tenant_id, one customer's data can be extracted with standard filtering and moved to a standalone instance without rewriting the application.","\u002Fimages\u002Fblog\u002Fblog_saas_multitenancy.jpg",{},"\u002Fblog\u002Fen\u002Fsaas-multitenancy-databases",{"title":1374,"description":2082},"blog\u002Fen\u002Fsaas-multitenancy-databases",[1368,2099,2100,2101,2102],"Databases","Architecture","PostgreSQL","SQL","mYFBuJiFCFdIzU4HHQhXKU34ogE4rNG33yYrIYqzgSk",1784561425286]