In my previous post about Telegram's native serverless runtime (tgcloud), I covered how the platform handles bot updates and where its limits sit (like the lack of native cron triggers).
Where tgcloud gets really interesting is when you build a full Telegram Mini App (TMA) on it.
Normally, shipping a Mini App means stitching together two or three separate cloud providers: static hosting on Cloudflare Pages or Vercel for the frontend, a Worker or container for the API, and an external database like D1, Turso, or Supabase. Every time a player taps a button in your Mini App, the request goes out over public DNS, negotiates TLS, runs Web Crypto HMAC-SHA256 verification against your bot token to prove initData wasn't forged, and makes another network hop to the database.
By pairing Astro (compiled as a flat SPA) with tgcloud's native RPC bridge and embedded SQLite (sdk/db), I was able to put the frontend, the backend endpoints, and the database inside the same tgcloud deployment with zero external hosting and sub-millisecond database reads.
1. Replacing fetch() and HMAC Checks with Native RPC
When a Mini App runs inside tgcloud, it doesn't need to call a public HTTPS API URL. Instead, the Telegram WebView exposes a native RPC method at window.Telegram.WebApp.Serverless.call that routes payloads directly from the client into your V8 isolate over Telegram's internal connection.
Because the callback signature is old-school (err, result), the cleanest setup is wrapping it in a small Promise helper (src/lib/api.ts) that uses Telegram.WebApp.Serverless.call in production and falls back to a standard POST /api/<endpoint> call when you are testing locally in a normal browser:
// src/lib/api.ts
declare global {
interface Window {
Telegram?: {
WebApp: {
Serverless?: {
call: (
endpointName: string,
payload: unknown,
callback: (err: { message: string } | null, result?: unknown) => void
) => void;
};
};
};
}
}
export async function callEndpoint<T = unknown>(
endpoint: string,
payload: Record<string, unknown> = {}
): Promise<T> {
// 1. Production: Native Telegram Serverless RPC bridge
if (typeof window !== "undefined" && window.Telegram?.WebApp?.Serverless?.call) {
return new Promise((resolve, reject) => {
window.Telegram!.WebApp.Serverless!.call(endpoint, payload, (err, res) => {
if (err) reject(new Error(err.message || "Serverless RPC error"));
else resolve(res as T);
});
});
}
// 2. Local Dev Fallback: standard browser localhost
const res = await fetch(`/api/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return res.json() as Promise<T>;
}On the backend, any file you drop into tgcloud/endpoints/<name>.js automatically maps to callEndpoint('<name>', payload).
Because the call comes through Telegram's internal gateway rather than a public HTTP endpoint, you don't have to write any HMAC-SHA256 token verification code. By the time your isolate wakes up, ctx.initData.user is already verified by the runtime:
// tgcloud/endpoints/action.js
import { EndpointError } from "sdk";
export default async function (input, ctx) {
const user = ctx.initData?.user;
if (!user?.id) {
throw new EndpointError("Unauthorized", { code: "UNAUTHORIZED" });
}
// user.id is already verified by Telegram's gateway
return { ok: true, userId: user.id };
}There is also no public IP or DNS record exposed to the internet for scrapers to hit—your endpoints can only be called through an authenticated Telegram session.
2. Hosting an Astro SPA on Telegram's Static CDN
People mostly think of Astro as a multi-page static site generator for blogs, but it works really well as a lightweight Mini App bundler when you set output: 'static' and build.format: 'file':
// astro.config.mjs
import { defineConfig } from "astro/config";
import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind";
export default defineConfig({
output: "static",
build: {
format: "file", // outputs flat files into dist/
},
integrations: [svelte(), tailwind()],
});Instead of deploying dist/ to Cloudflare Pages or Vercel, you point tgcloud.jsonc at your build output directory:
// tgcloud.jsonc
{
"$schema": "./node_modules/@tgcloud/cli/schema/tgcloud.json",
"static": {
"source": "dist",
"spa": true,
"immutable": ["/assets/*"]
}
}Setting "spa": true tells Telegram's static CDN to route extensionless paths back to index.html, and "immutable": ["/assets/*"] attaches Cache-Control: max-age=31536000, immutable headers to Astro's hashed bundles. Running npx tgcloud push uploads both your backend isolate endpoints and your Astro frontend in a single command.
3. Embedded SQLite (sdk/db) Inside the Isolate
The biggest latency win comes from the database layer. Every tgcloud bot gets an embedded SQLite database colocated with the runtime, exposed through a built-in Drizzle-style query builder (sdk/db).
For a game or interactive Mini App, a practical pattern is hybrid modeling: keep the fields you filter or index on (userId, rank, gold, notificationsEnabled, lastSyncedAt) as typed SQLite columns, and store the nested player state tree (inventory, quests, settings) in a json('state') column:
// tgcloud/schema.js
import { table, integer, text, json, boolean, sql, index } from "sdk/db";
export const players = table(
"players",
{
userId: integer("user_id").primaryKey(),
username: text("username").default(""),
rank: text("rank").notNull().default("F"),
gold: integer("gold").notNull().default(150),
state: json("state").notNull(),
lastSyncedAt: integer("last_synced_at").notNull(),
notificationsEnabled: boolean("notifications_enabled").default(true),
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
},
(t) => ({
pushIdx: index("idx_players_push").on(t.notificationsEnabled, t.lastSyncedAt),
})
);You push schema changes from your terminal with:
npx tgcloud migrate --yesInside your endpoint (tgcloud/endpoints/sync.js), querying or initializing a player record runs locally against SQLite in under a millisecond—without TCP connection pools or TLS handshakes to an external database host:
// tgcloud/endpoints/sync.js
import { db, EndpointError } from "sdk";
import { eq } from "sdk/db";
import { players } from "../schema.js";
export default async function (input, ctx) {
const userId = ctx.initData?.user?.id;
if (!userId) {
throw new EndpointError("Unauthorized", { code: "UNAUTHORIZED" });
}
let player = await db
.select()
.from(players)
.where(eq(players.userId, userId))
.get();
if (!player) {
player = await db
.insert(players)
.values({
userId,
username: ctx.initData.user.username || "",
state: { inventory: [], completedQuests: [] },
lastSyncedAt: Math.floor(Date.now() / 1000),
})
.returning()
.get();
}
return {
player,
serverNow: Math.floor(Date.now() / 1000),
};
}Multi-Vendor TMA Stack vs. Colocated tgcloud Stack
Here is how the setup compares against a standard multi-vendor Mini App deployment:
| Layer | Standard Multi-Vendor TMA Stack | tgcloud Colocated Stack |
|---|---|---|
| Services to Manage | Frontend CDN + API Worker/Server + External DB | Single tgcloud project |
| Client-to-Server Transport | Public HTTPS fetch() + CORS headers | Internal RPC (Telegram.WebApp.Serverless.call) |
| User Authentication | Manual HMAC-SHA256 check on initData | Pre-verified ctx.initData.user from gateway |
| Database Latency | 20ms – 80ms (remote network roundtrip) | < 1ms (colocated embedded SQLite via sdk/db) |
| Deployment | Separate frontend build, API deploy, and DB migrations | astro build && npx tgcloud push |