Build a Telegram Bot with grammY and TypeScript: From Zero to Deployment

Telegram bots are everywhere. From customer support to task automation, Telegram's Bot API powers millions of bots. In this tutorial, you will build a production-ready Telegram bot using grammY — the modern, type-safe TypeScript framework for building Telegram bots.
What You Will Learn
By the end of this tutorial, you will:
- Create a Telegram bot and obtain an API token from BotFather
- Set up a TypeScript project with grammY
- Handle commands, messages, and callback queries
- Build interactive inline keyboards and menus
- Use middleware and composers to organize your code
- Implement session-based state management
- Add conversation flows with
@grammyjs/conversations - Deploy your bot using long polling and webhooks
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript knowledge (types, async/await, modules)
- A Telegram account (to create and test the bot)
- Basic understanding of HTTP and APIs
- A code editor (VS Code recommended)
Step 1: Create Your Bot with BotFather
Every Telegram bot starts with BotFather — Telegram's official bot for managing bots.
- Open Telegram and search for
@BotFather - Send
/newbot - Choose a display name (e.g., "My grammY Bot")
- Choose a username ending in
bot(e.g.,my_grammy_demo_bot) - Copy the API token you receive — it looks like:
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
Keep your token secret! Anyone with your bot token can control your bot. Never commit it to version control.
Step 2: Project Setup
Create a new project and install dependencies:
mkdir telegram-grammy-bot && cd telegram-grammy-bot
npm init -y
npm install grammy dotenv
npm install -D typescript @types/node tsx
npx tsc --initUpdate tsconfig.json for modern TypeScript:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true
},
"include": ["src"]
}Add "type": "module" to your package.json and update scripts:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/bot.ts",
"build": "tsc",
"start": "node dist/bot.js"
}
}Create a .env file for your bot token:
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11Step 3: Your First Bot
Create src/bot.ts:
import { Bot } from "grammy";
import "dotenv/config";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("BOT_TOKEN is not set");
const bot = new Bot(token);
// Handle the /start command
bot.command("start", (ctx) => {
ctx.reply(
`Hello ${ctx.from?.first_name}! 👋\nI'm a bot built with grammY and TypeScript.`
);
});
// Handle the /help command
bot.command("help", (ctx) => {
ctx.reply(
"Available commands:\n" +
"/start - Start the bot\n" +
"/help - Show this help message\n" +
"/about - Learn about this bot"
);
});
// Echo any text message
bot.on("message:text", (ctx) => {
ctx.reply(`You said: ${ctx.message.text}`);
});
// Start the bot
bot.start();
console.log("Bot is running...");Run it:
npm run devOpen Telegram, find your bot by its username, and send /start. You should see a greeting message.
Step 4: Inline Keyboards and Callback Queries
Inline keyboards add interactive buttons to your messages. Create src/keyboards.ts:
import { InlineKeyboard } from "grammy";
export const mainMenu = new InlineKeyboard()
.text("📊 Status", "status")
.text("⚙️ Settings", "settings")
.row()
.text("ℹ️ About", "about")
.text("📞 Contact", "contact");
export const settingsMenu = new InlineKeyboard()
.text("🌍 Language", "lang")
.text("🔔 Notifications", "notif")
.row()
.text("« Back to Menu", "back_to_menu");Now handle the callback queries in src/bot.ts:
import { Bot } from "grammy";
import { mainMenu, settingsMenu } from "./keyboards.js";
import "dotenv/config";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("BOT_TOKEN is not set");
const bot = new Bot(token);
bot.command("start", (ctx) => {
ctx.reply("Welcome! Choose an option:", { reply_markup: mainMenu });
});
// Handle button callbacks
bot.callbackQuery("status", (ctx) => {
ctx.answerCallbackQuery();
ctx.editMessageText("✅ Bot is running smoothly!\n\nUptime: 99.9%", {
reply_markup: new InlineKeyboard().text("« Back", "back_to_menu"),
});
});
bot.callbackQuery("settings", (ctx) => {
ctx.answerCallbackQuery();
ctx.editMessageText("⚙️ Settings:", { reply_markup: settingsMenu });
});
bot.callbackQuery("about", async (ctx) => {
await ctx.answerCallbackQuery();
await ctx.editMessageText(
"This bot was built with grammY + TypeScript.\nVersion: 1.0.0",
{ reply_markup: new InlineKeyboard().text("« Back", "back_to_menu") }
);
});
bot.callbackQuery("back_to_menu", (ctx) => {
ctx.answerCallbackQuery();
ctx.editMessageText("Choose an option:", { reply_markup: mainMenu });
});
bot.start();Always call ctx.answerCallbackQuery() when handling callback queries. This removes the loading indicator on the user's button. Telegram will show a timeout error if you do not answer within 30 seconds.
Step 5: Middleware and Composers
As your bot grows, you need to organize handlers. grammY's Composer pattern lets you modularize your code.
Create src/handlers/admin.ts:
import { Composer } from "grammy";
import type { MyContext } from "../types.js";
const admin = new Composer<MyContext>();
const ADMIN_IDS = [123456789]; // Replace with your Telegram user ID
admin.command("broadcast", async (ctx) => {
if (!ADMIN_IDS.includes(ctx.from?.id ?? 0)) {
return ctx.reply("⛔ You are not authorized to use this command.");
}
const message = ctx.match;
if (!message) {
return ctx.reply("Usage: /broadcast <message>");
}
await ctx.reply(`📢 Broadcasting: ${message}`);
});
admin.command("stats", async (ctx) => {
if (!ADMIN_IDS.includes(ctx.from?.id ?? 0)) {
return ctx.reply("⛔ Unauthorized.");
}
await ctx.reply("📊 Bot Statistics:\n- Users: 42\n- Messages today: 156");
});
export default admin;Create src/types.ts for shared types:
import type { Context, SessionFlavor } from "grammy";
export interface SessionData {
language: string;
notificationsEnabled: boolean;
messageCount: number;
}
export type MyContext = Context & SessionFlavor<SessionData>;Register the composer in your main bot file:
import admin from "./handlers/admin.js";
// ... bot setup ...
bot.use(admin);Step 6: Session Management
Sessions let you store per-user data across messages. Install the session plugin:
npm install @grammyjs/storage-fileUpdate src/bot.ts to use sessions:
import { Bot, session } from "grammy";
import type { MyContext, SessionData } from "./types.js";
import "dotenv/config";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("BOT_TOKEN is not set");
const bot = new Bot<MyContext>(token);
function defaultSession(): SessionData {
return {
language: "en",
notificationsEnabled: true,
messageCount: 0,
};
}
bot.use(
session({
initial: defaultSession,
})
);
// Middleware to count messages
bot.use(async (ctx, next) => {
if (ctx.message) {
ctx.session.messageCount++;
}
await next();
});
bot.command("mystats", (ctx) => {
const { language, notificationsEnabled, messageCount } = ctx.session;
ctx.reply(
`📊 Your Stats:\n` +
`- Language: ${language}\n` +
`- Notifications: ${notificationsEnabled ? "On" : "Off"}\n` +
`- Messages sent: ${messageCount}`
);
});
bot.command("setlang", (ctx) => {
const lang = ctx.match;
if (!lang || !["en", "ar", "fr"].includes(lang)) {
return ctx.reply("Usage: /setlang <en|ar|fr>");
}
ctx.session.language = lang;
ctx.reply(`✅ Language set to: ${lang}`);
});
bot.start();Step 7: Conversation Flows
For multi-step interactions (like forms or wizards), use the conversations plugin:
npm install @grammyjs/conversationsCreate src/conversations/feedback.ts:
import type { MyContext } from "../types.js";
import type { Conversation } from "@grammyjs/conversations";
type FeedbackConversation = Conversation<MyContext>;
export async function feedbackFlow(
conversation: FeedbackConversation,
ctx: MyContext
) {
await ctx.reply("📝 I'd love to hear your feedback!\n\nWhat's your name?");
const nameCtx = await conversation.waitFor("message:text");
const name = nameCtx.message.text;
await ctx.reply(`Thanks ${name}! How would you rate our service? (1-5)`);
let rating: number;
do {
const ratingCtx = await conversation.waitFor("message:text");
rating = parseInt(ratingCtx.message.text);
if (isNaN(rating) || rating < 1 || rating > 5) {
await ctx.reply("Please enter a number between 1 and 5.");
}
} while (isNaN(rating) || rating < 1 || rating > 5);
await ctx.reply("Any additional comments? (Send /skip to skip)");
const commentCtx = await conversation.waitFor("message:text");
const comment =
commentCtx.message.text === "/skip" ? "No comment" : commentCtx.message.text;
await ctx.reply(
`✅ Feedback received!\n\n` +
`Name: ${name}\n` +
`Rating: ${"⭐".repeat(rating)}\n` +
`Comment: ${comment}\n\n` +
`Thank you for your feedback!`
);
}Register it in your bot:
import { conversations, createConversation } from "@grammyjs/conversations";
import { feedbackFlow } from "./conversations/feedback.js";
// Add after session middleware
bot.use(conversations());
bot.use(createConversation(feedbackFlow));
bot.command("feedback", async (ctx) => {
await ctx.conversation.enter("feedbackFlow");
});Step 8: Error Handling
Production bots need robust error handling:
import { BotError, GrammyError, HttpError } from "grammy";
bot.catch((err: BotError) => {
const ctx = err.ctx;
console.error(`Error while handling update ${ctx.update.update_id}:`);
const e = err.error;
if (e instanceof GrammyError) {
console.error("Error in request:", e.description);
if (e.description.includes("message is not modified")) {
// User clicked the same button twice — safe to ignore
return;
}
} else if (e instanceof HttpError) {
console.error("Could not contact Telegram:", e);
} else {
console.error("Unknown error:", e);
}
});Step 9: Webhook Deployment
For production, webhooks are more efficient than long polling. Here is how to set up a webhook with grammY and Hono:
npm install hono @hono/node-serverCreate src/webhook.ts:
import { Bot, webhookCallback } from "grammy";
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import type { MyContext } from "./types.js";
import "dotenv/config";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("BOT_TOKEN is not set");
const bot = new Bot<MyContext>(token);
// ... register all your handlers, middleware, sessions, etc. ...
bot.command("start", (ctx) => ctx.reply("Hello from webhook mode!"));
const app = new Hono();
app.get("/", (c) => c.text("Bot is running"));
app.post(`/webhook/${token}`, webhookCallback(bot, "hono"));
const PORT = parseInt(process.env.PORT || "3000");
serve({ fetch: app.fetch, port: PORT }, () => {
console.log(`Webhook server running on port ${PORT}`);
});
// Set the webhook URL (run once)
// bot.api.setWebhook(`https://your-domain.com/webhook/${token}`);Set up the webhook by calling the Telegram API:
curl -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-domain.com/webhook/<YOUR_TOKEN>"}'Step 10: Deploy to a VPS
Create a Dockerfile for deployment:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY .env .env
EXPOSE 3000
CMD ["node", "dist/webhook.js"]Build and run:
docker build -t grammy-bot .
docker run -d --name grammy-bot -p 3000:3000 grammy-botUse a reverse proxy like Caddy or nginx to handle HTTPS:
# Caddyfile
bot.yourdomain.com {
reverse_proxy localhost:3000
}
Project Structure
Here is the final project structure:
telegram-grammy-bot/
├── src/
│ ├── bot.ts # Main bot entry (long polling)
│ ├── webhook.ts # Webhook entry point
│ ├── types.ts # Shared types
│ ├── keyboards.ts # Inline keyboard definitions
│ ├── handlers/
│ │ └── admin.ts # Admin commands composer
│ └── conversations/
│ └── feedback.ts # Feedback conversation flow
├── .env
├── package.json
├── tsconfig.json
└── Dockerfile
Troubleshooting
Bot not responding?
- Verify your token is correct and not expired
- Check if another instance of the bot is running (only one can use long polling)
- Make sure the bot is not blocked by the user
Webhook not working?
- Ensure your server has a valid SSL certificate (HTTPS is required)
- Check the webhook URL using
https://api.telegram.org/bot<TOKEN>/getWebhookInfo - Verify your server is publicly accessible
Session data lost?
- In-memory sessions are cleared on restart. Use
@grammyjs/storage-fileor a database adapter for persistence - Check that the session middleware is registered before your handlers
Next Steps
- Add database integration with Prisma or Drizzle for persistent storage
- Implement internationalization using
@grammyjs/i18n - Add rate limiting with
@grammyjs/ratelimiter - Build a web app using Telegram's Mini Apps feature
- Set up payments using Telegram's built-in payment system
- Add file handling for images, documents, and voice messages
Conclusion
You have built a fully functional Telegram bot with grammY and TypeScript, covering:
- Bot creation and token management
- Command and message handling
- Interactive inline keyboards
- Modular code with composers
- Session-based state management
- Multi-step conversation flows
- Error handling and production deployment
grammY's TypeScript-first approach gives you excellent autocompletion, type safety, and a clean API. Combined with its plugin ecosystem, it is the ideal framework for building Telegram bots in 2026.
The complete source code for this tutorial is available for reference. Start building your own bot and explore the rich Telegram Bot API to create powerful automations and interactive experiences.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build Your First MCP Server with TypeScript: Tools, Resources, and Prompts
Learn how to build a production-ready MCP server from scratch using TypeScript. This hands-on tutorial covers tools, resources, prompts, stdio transport, and connecting to Claude Desktop and Cursor.

Build and Deploy a Serverless API with Cloudflare Workers, Hono, and D1
Learn how to build a production-ready serverless REST API using Cloudflare Workers, the Hono web framework, and D1 SQLite database — from project setup to global deployment.

Build End-to-End Type-Safe APIs with tRPC and Next.js App Router
Learn how to build fully type-safe APIs with tRPC and Next.js 15 App Router. This hands-on tutorial covers router setup, procedures, middleware, React Query integration, and server-side calls — all without writing a single API schema.