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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.

  1. Open Telegram and search for @BotFather
  2. Send /newbot
  3. Choose a display name (e.g., "My grammY Bot")
  4. Choose a username ending in bot (e.g., my_grammy_demo_bot)
  5. 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 --init

Update 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-zyx57W2v1u123ew11

Step 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 dev

Open 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-file

Update 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/conversations

Create 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-server

Create 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-bot

Use 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-file or 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.


Want to read more tutorials? Check out our latest tutorial on Integrating ALLaM-7B-Instruct-preview with Ollama.

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 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.

28 min read·