Building a Secure Contact Form with Cloudflare Functions and Telegram API
Static sites offer incredible performance, security, and simplicity - but they come with a fundamental limitation: without a backend server, how do you handle contact forms securely? You can’t expose API keys in client-side JavaScript, and traditional email solutions require server infrastructure or expensive third-party services.
Over the past year building my portfolio at yojahny.dev, I needed a contact form solution that was secure, instant, cost-free, and didn’t require maintaining email infrastructure. The solution? Combining Cloudflare Functions (serverless edge computing) with Telegram’s Bot API to create a production-ready contact form that delivers messages instantly to my phone.
This comprehensive guide walks through the complete implementation - from creating your Telegram bot to deploying the Cloudflare Function, with real production code and lessons learned from handling actual contact form submissions in a cyberpunk-themed portfolio.
Who This Guide Is For
This tutorial is designed for:
- Frontend developers building static sites (Astro, Next.js, Hugo, Jekyll) who need backend functionality
- JAMstack developers seeking serverless solutions for form handling without third-party services
- Full-stack developers exploring edge computing and serverless architecture patterns
- Portfolio builders who want instant contact notifications without email infrastructure
- Web developers concerned about API security and credential protection in client-side applications
You should be comfortable with JavaScript/TypeScript, basic API concepts, and static site deployment. No prior experience with Cloudflare Functions or Telegram bots required - we’ll cover everything step by step.
The Problem: Static Sites Can’t Hide API Credentials
Static sites are compiled to HTML, CSS, and JavaScript that runs entirely in the user’s browser. This architecture provides incredible benefits:
- Lightning-fast page loads served from CDNs
- Minimal hosting costs (often free)
- No server vulnerabilities or maintenance
- Perfect lighthouse scores
- Simple deployment workflows
But here’s the catch: anything in client-side JavaScript is visible to anyone. If you try to send emails directly from JavaScript, you’d need to embed API keys in your code - which means anyone can:
- View your source code and extract the API key
- Use your credentials to send unlimited emails
- Rack up charges on your account (or worse, use it for spam)
- Compromise your security completely
Traditional solutions have their own issues:
- Form services (Formspree, Netlify Forms): Cost money for higher volumes, lock you into a platform
- Email SMTP directly: Requires server infrastructure, slow, gets caught in spam filters
- AWS SES/SendGrid: API keys still need protection, complex setup, cost scaling concerns
- Backend server: Defeats the purpose of a static site
We need a different approach: a secure backend proxy that handles credentials server-side while keeping our static site architecture intact.
Why Telegram API Instead of Email?
Before diving into implementation, let’s address why Telegram beats traditional email for contact form notifications:
Instant Delivery, Zero Configuration
Telegram messages arrive on your phone in milliseconds. No SMTP configuration, no DNS records, no SPF/DKIM/DMARC setup. Create a bot, get a token, done.
Rich Formatting and Context
Telegram supports HTML formatting, allowing you to create structured, readable contact messages with emoji indicators, bold headers, and clickable email links. Messages arrive with full context, not plain text.
No Spam Filters or Deliverability Issues
Email deliverability is a nightmare. Even legitimate transactional emails get flagged, delayed, or lost. Telegram messages always arrive, instantly, every time.
Free and Generous Rate Limits
Telegram’s Bot API is completely free with a rate limit of 30 messages per second. Unless your contact form gets 2.5 million submissions per day, you’ll never hit limits. Most email services charge per message after a free tier.
Mobile-First Workflow
As a developer who’s often mobile, getting contact notifications directly to my phone (not buried in an email inbox) means faster response times. I can reply directly from Telegram or flag for later action.
Professional Workflow Integration
Telegram bots integrate with automation tools, can forward to team channels, support inline keyboards for quick actions, and connect to notification systems. Your contact form becomes part of a professional workflow.
Developer-Friendly API
The Telegram Bot API is clean, well-documented, and requires zero authentication beyond a simple token. Compare this to OAuth flows, webhook verification, or complex email service APIs.
Real-world impact: Since implementing this system on my portfolio, I’ve received every contact form submission within 3 seconds, never missed a message due to spam filters, and saved approximately $10/month compared to email service alternatives.
Why Cloudflare Functions?
Cloudflare Functions (also called Cloudflare Pages Functions) are lightweight serverless functions that run on Cloudflare’s global edge network. Here’s why they’re perfect for this use case:
Zero Cold Starts
Unlike AWS Lambda or traditional serverless platforms, Cloudflare Functions run on Cloudflare’s V8 isolates architecture - meaning virtually zero cold start latency. Your contact form responds instantly, every time.
Generous Free Tier
Cloudflare Pages Functions include 100,000 requests per day on the free tier. Even a popular portfolio site rarely exceeds a few hundred contact form submissions monthly. This is effectively unlimited for most projects.
Integrated Deployment
If you’re already hosting on Cloudflare Pages (which supports Astro, Next.js, and most static site generators), Functions deploy automatically with your site. No separate configuration or deployment pipeline needed.
Global Edge Network
Functions run on Cloudflare’s 250+ data center locations worldwide. Users in Tokyo, London, or São Paulo all experience the same fast response times - not a single-region server half the world away.
Built-in Environment Variables
Cloudflare Pages provides secure environment variable management in the dashboard. Your API credentials never touch your codebase, and changes deploy without rebuilding your site.
Simple Development Experience
Functions use a file-based routing system. Create /functions/api/send-telegram-message.ts and it automatically creates the /api/send-telegram-message endpoint. No complex configuration files or routing logic required.
Seamless Integration with Pages
Unlike separate serverless platforms, Cloudflare Functions share the same domain as your static site. No CORS complications, no separate API gateway, no additional DNS configuration.
Cost Comparison
| Platform | Free Tier | Overage Cost |
|---|---|---|
| Cloudflare Functions | 100,000 req/day | $0.50 per million |
| AWS Lambda | 1M req/month | $0.20 per million + duration |
| Vercel Edge Functions | 100,000 req/day | $20 per million |
| Netlify Functions | 125k req/month | $25 per million |
For contact forms, Cloudflare offers the best value with the simplest integration.
Architecture Overview: How the System Works
Before we start building, let’s understand how data flows through the system:
- User fills out contact form on your static site (HTML/JavaScript)
- Client-side validation checks form fields before submission
- JavaScript sends POST request to
/api/send-telegram-messageendpoint - Cloudflare Function receives request at the edge (runs server-side)
- Function retrieves credentials from environment variables (secure, never exposed to client)
- Function validates request data (name, email, subject, message)
- Function formats message with HTML formatting for readability
- Function calls Telegram Bot API server-side using bot token
- Telegram delivers message to your phone instantly
- Function returns response to client (success or error)
- Client shows feedback to user (success message or error state)
The key security insight: API credentials live on Cloudflare’s servers, never in your JavaScript bundle. The client only knows about the /api/send-telegram-message endpoint - it has no idea how that endpoint works or what credentials it uses.
This is the API Proxy Pattern - your Cloudflare Function acts as a secure proxy between public clients and authenticated services, hiding credentials while providing controlled access.
Step 1: Create Your Telegram Bot
Let’s start by setting up the Telegram side. This takes about 2 minutes.
Talk to BotFather
Telegram uses a bot called “BotFather” to manage all other bots. Open Telegram and:
- Search for
@BotFatheror open t.me/BotFather - Start a conversation with
/start - Create a new bot with
/newbot - Choose a display name: “Portfolio Contact Bot” (or anything you want)
- Choose a username:
your_portfolio_contact_bot(must end with “bot”)
BotFather will respond with your bot token - a string that looks like:
1234567890:ABCdefGHIjklMNOpqrsTUVwxyz123456789
CRITICAL: This token is your bot’s password. Anyone with this token can control your bot. Never commit it to version control, never share it publicly, never embed it in client-side code.
Copy this token somewhere safe - you’ll need it for your Cloudflare Function.
Get Your Chat ID
Your bot needs to know where to send messages. Telegram uses Chat IDs to identify users, groups, and channels. Here’s how to find yours:
- Send any message to your bot (find it by searching its username)
- Open this URL in your browser, replacing
YOUR_BOT_TOKEN:
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
- Look for the
"chat"object in the JSON response:
{
"ok": true,
"result": [
{
"update_id": 123456789,
"message": {
"chat": {
"id": 987654321,
"first_name": "Your Name",
"type": "private"
}
}
}
]
}
The "id" value (like 987654321) is your Chat ID. Copy this - you’ll need it alongside your bot token.
Alternative method: Use the @userinfobot bot. Send it any message and it replies with your user ID.
Test Your Bot
Verify everything works before moving to the next step:
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage" \
-H "Content-Type: application/json" \
-d '{"chat_id": "<YOUR_CHAT_ID>", "text": "Test message from my contact form!"}'
You should receive a message on Telegram immediately. If you get an error:
401 Unauthorized: Wrong bot token400 Bad Request: Wrong chat ID or invalid parametersToo many requests: Wait 30 seconds and try again
Step 2: Set Up Your Cloudflare Function
Now we build the secure backend that proxies requests to Telegram. This is the complete production code from my portfolio.
Project Structure
Cloudflare Functions use file-based routing. In your project:
your-project/
├── functions/
│ └── api/
│ └── send-telegram-message.ts
├── src/
│ └── config/
│ └── telegram.ts
├── public/
└── package.json
The functions/ directory at your project root automatically deploys as serverless functions. Any file in functions/api/ becomes an endpoint at /api/.
The Complete Cloudflare Function
Create /functions/api/send-telegram-message.ts:
// Cloudflare Function: Secure Telegram Bot API Proxy
// This function runs server-side and keeps your bot token private
interface Env {
TELEGRAM_BOT_TOKEN: string;
TELEGRAM_CHAT_ID: string;
}
interface ContactFormData {
name: string;
email: string;
subject: string;
message: string;
}
// CORS headers for the response
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // In production, replace with your domain
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestPost: PagesFunction<Env> = async (context) => {
try {
// Get environment variables
const botToken = context.env.TELEGRAM_BOT_TOKEN;
const chatId = context.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
return new Response(
JSON.stringify({
success: false,
error: 'Server configuration error',
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Parse request body
const data = (await context.request.json()) as ContactFormData;
// Validate required fields
if (!data.name || !data.email || !data.subject || !data.message) {
return new Response(
JSON.stringify({
success: false,
error: 'Missing required fields',
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Format message for Telegram
const telegramMessage = formatTelegramMessage(
data.name,
data.email,
data.subject,
data.message
);
// Send message to Telegram
const telegramResponse = await fetch(
`https://api.telegram.org/bot${botToken}/sendMessage`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text: telegramMessage,
parse_mode: 'HTML',
}),
}
);
const telegramData = await telegramResponse.json();
if (!telegramResponse.ok) {
console.error('Telegram API error:', telegramData);
return new Response(
JSON.stringify({
success: false,
error: 'Failed to send message',
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Success response
return new Response(
JSON.stringify({
success: true,
message: 'Message sent successfully',
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Error in Cloudflare Function:', error);
return new Response(
JSON.stringify({
success: false,
error: 'Internal server error',
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
};
// Handle CORS preflight
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
};
// Format message for Telegram
function formatTelegramMessage(
name: string,
email: string,
subject: string,
message: string
): string {
return `
🔔 <b>NEW CONTACT FORM MESSAGE</b>
👤 <b>From:</b> ${escapeHtml(name)}
📧 <b>Email:</b> ${escapeHtml(email)}
📝 <b>Subject:</b> ${escapeHtml(subject)}
💬 <b>Message:</b>
${escapeHtml(message)}
---
📅 <i>Sent from yojahny.dev portfolio</i>
`.trim();
}
// Escape HTML special characters
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
Code Breakdown: Understanding Each Part
Let’s examine the key components of this function:
1. TypeScript Interfaces for Type Safety
interface Env {
TELEGRAM_BOT_TOKEN: string;
TELEGRAM_CHAT_ID: string;
}
Cloudflare provides environment variables through the Env interface. This gives you TypeScript autocomplete and compile-time checking for your secrets.
2. CORS Headers for Browser Compatibility
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
Browsers enforce CORS (Cross-Origin Resource Sharing) for security. Even though your function and site share a domain, you need these headers. In production, replace '*' with your actual domain: 'https://yojahny.dev'.
3. Environment Variable Validation
const botToken = context.env.TELEGRAM_BOT_TOKEN;
const chatId = context.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
return new Response(/* error */);
}
Always validate that environment variables exist before using them. Missing variables should return a 500 error (server misconfiguration), not crash the function.
4. Input Validation
if (!data.name || !data.email || !data.subject || !data.message) {
return new Response(/* 400 error */);
}
Never trust client input. Validate all required fields exist before processing. Missing data returns 400 (client error), not 500 (server error).
5. Message Formatting with HTML
const telegramMessage = formatTelegramMessage(
data.name,
data.email,
data.subject,
data.message
);
Telegram supports HTML formatting for rich messages. The formatTelegramMessage function creates a structured, readable message with emoji indicators and bold headers.
6. HTML Escaping for Security
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
// ...
}
Critical security measure: Always escape user input before including it in HTML. If a malicious user submits <script>alert('xss')</script> as their name, it becomes <script>alert('xss')</script> - rendered as text, not executed.
7. Error Handling Strategy
try {
// Main logic
} catch (error) {
console.error('Error in Cloudflare Function:', error);
return new Response(/* 500 error */);
}
Wrap everything in try-catch to handle unexpected errors gracefully. Log errors (visible in Cloudflare dashboard) but return generic error messages to clients (don’t leak implementation details).
8. CORS Preflight Handler
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
};
Browsers send an OPTIONS request before POST to check CORS permissions. This handler returns 204 (No Content) with CORS headers, allowing the actual POST request to proceed.
Step 3: Configure Environment Variables
Your function needs credentials, but they must never exist in your codebase. Cloudflare Pages provides secure environment variable management.
Add Variables in Cloudflare Dashboard
- Go to Cloudflare Dashboard
- Navigate to Pages > Your Project
- Click Settings > Environment Variables
- Add two variables for Production:
- Name:
TELEGRAM_BOT_TOKEN, Value:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz... - Name:
TELEGRAM_CHAT_ID, Value:987654321
- Name:
- Optionally add for Preview (separate bot for testing deployments)
- Click Save
Environment Variable Best Practices
- Never commit secrets to Git: Environment variables exist only in Cloudflare’s secure storage
- Use separate bots for staging/production: Different tokens for preview and production deployments
- Rotate credentials periodically: If a token leaks, create a new bot and update variables
- Document required variables: List them in README so collaborators know what’s needed
- Validate on startup: Function checks variables exist before processing requests
Security note: Even with access to your GitHub repository, attackers cannot steal your credentials because they only exist in Cloudflare’s secure environment. This is the core security advantage of the proxy pattern.
Step 4: Create Client-Side Integration
Now we build the client-side code that calls your Cloudflare Function. This TypeScript module abstracts the API call.
Create /src/config/telegram.ts:
// Telegram Bot Configuration (Client-side)
// This file calls a secure Cloudflare Function that handles the Telegram API
// Bot token and chat ID are stored as environment variables in Cloudflare
// Function to send message via Cloudflare Function proxy
// The actual Telegram API call happens server-side to keep credentials secure
export async function sendTelegramMessage(
name: string,
email: string,
subject: string,
message: string
): Promise<{ success: boolean; error?: string }> {
try {
// Call the Cloudflare Function endpoint
const response = await fetch('/api/send-telegram-message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
email,
subject,
message,
}),
});
const data = await response.json();
if (!response.ok) {
console.error('API error:', data);
return {
success: false,
error: data.error || 'Failed to send message',
};
}
return { success: true };
} catch (error) {
console.error('Error sending message:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
Why This Abstraction Matters
This client-side module provides several benefits:
- Abstraction: Components don’t know implementation details - they just call
sendTelegramMessage() - Type Safety: TypeScript ensures you pass correct parameters
- Reusability: Any component can import and use this function
- Error Handling: Centralized error handling and response parsing
- Testability: Mock this function in tests instead of mocking fetch directly
The key insight: From the client’s perspective, this could be calling Telegram, SendGrid, or a custom backend - it doesn’t matter. The implementation is hidden behind a clean interface.
Step 5: Build the Contact Form UI
Now let’s build the actual form that users interact with. This example is from my Astro-based portfolio with cyberpunk styling, but the patterns work with any framework.
Form HTML Structure (Simplified)
<form id="contact-form">
<!-- Name Field -->
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name" required />
<div class="error-message hidden"></div>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required />
<div class="error-message hidden"></div>
</div>
<!-- Subject Field -->
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text" id="subject" name="subject" required />
<div class="error-message hidden"></div>
</div>
<!-- Message Field -->
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" rows="6" required></textarea>
<div class="error-message hidden"></div>
</div>
<!-- Submit Button -->
<button type="submit" id="submit-btn">
<span class="btn-text">Send Message</span>
<span class="btn-loading hidden">Sending...</span>
</button>
<!-- Status Messages -->
<div id="form-success" class="hidden">Message sent successfully!</div>
<div id="form-error" class="hidden">
<span id="error-text">Failed to send message. Please try again.</span>
</div>
</form>
Form Validation and Submission
import { sendTelegramMessage } from '../../config/telegram.ts';
// Form validation rules
const validationRules = {
name: {
required: true,
minLength: 2,
maxLength: 50,
pattern: /^[a-zA-Z\s'-]+$/,
message: 'Name must be 2-50 characters'
},
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address'
},
subject: {
required: true,
minLength: 5,
maxLength: 100,
message: 'Subject must be 5-100 characters'
},
message: {
required: true,
minLength: 10,
maxLength: 1000,
message: 'Message must be 10-1000 characters'
}
};
// Get form elements
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
const successMessage = document.getElementById('form-success');
const errorMessage = document.getElementById('form-error');
const errorText = document.getElementById('error-text');
// Validation function
function validateField(field) {
const fieldName = field.name;
const rules = validationRules[fieldName];
const value = field.value.trim();
const errorElement = field.parentElement.querySelector('.error-message');
// Clear previous errors
field.classList.remove('border-error', 'border-success');
errorElement.classList.add('hidden');
// Required validation
if (rules.required && !value) {
showFieldError(field, errorElement, 'This field is required');
return false;
}
// Length validation
if (rules.minLength && value.length < rules.minLength) {
showFieldError(field, errorElement, rules.message);
return false;
}
if (rules.maxLength && value.length > rules.maxLength) {
showFieldError(field, errorElement, rules.message);
return false;
}
// Pattern validation
if (rules.pattern && !rules.pattern.test(value)) {
showFieldError(field, errorElement, rules.message);
return false;
}
// Show success state
field.classList.add('border-success');
return true;
}
function showFieldError(field, errorElement, message) {
field.classList.add('border-error');
errorElement.textContent = message;
errorElement.classList.remove('hidden');
}
// Real-time validation
form.querySelectorAll('input, textarea').forEach(field => {
// Validate on blur
field.addEventListener('blur', () => {
if (field.value.trim()) {
validateField(field);
}
});
// Clear errors when typing
field.addEventListener('input', () => {
if (field.classList.contains('border-error')) {
validateField(field);
}
});
});
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Hide previous messages
successMessage.classList.add('hidden');
errorMessage.classList.add('hidden');
// Validate all fields
const fields = form.querySelectorAll('input, textarea');
let isValid = true;
fields.forEach(field => {
if (!validateField(field)) {
isValid = false;
}
});
if (!isValid) {
// Scroll to first error
const firstError = form.querySelector('.border-error');
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
// Show loading state
submitBtn.disabled = true;
btnText.classList.add('hidden');
btnLoading.classList.remove('hidden');
try {
// Get form data
const formData = new FormData(form);
const name = formData.get('name');
const email = formData.get('email');
const subject = formData.get('subject');
const message = formData.get('message');
// Send via Telegram
const result = await sendTelegramMessage(name, email, subject, message);
if (result.success) {
// Show success message
successMessage.classList.remove('hidden');
// Reset form
form.reset();
fields.forEach(field => {
field.classList.remove('border-success');
});
// Scroll to success message
successMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
throw new Error(result.error || 'Failed to send message');
}
} catch (error) {
console.error('Form submission error:', error);
// Show error message
errorText.textContent = 'Failed to send message. Please try again or use direct email.';
errorMessage.classList.remove('hidden');
// Scroll to error message
errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
} finally {
// Reset button state
submitBtn.disabled = false;
btnText.classList.remove('hidden');
btnLoading.classList.add('hidden');
}
});
Key Form Implementation Details
1. Progressive Validation
Forms validate in three stages:
- On blur: When user leaves a field (provides immediate feedback)
- On input: Re-validates if field had an error (clears errors as user fixes them)
- On submit: Final validation before sending (catches any missed fields)
This provides the best user experience - feedback when needed, but not annoying during typing.
2. Loading States
submitBtn.disabled = true;
btnText.classList.add('hidden');
btnLoading.classList.remove('hidden');
Always disable the submit button and show loading state during submission. This prevents duplicate submissions if the user clicks twice and provides clear feedback that something is happening.
3. Error Recovery
catch (error) {
errorText.textContent = 'Failed to send message. Please try again or use direct email.';
}
When things fail, provide actionable feedback. Tell users they can try again or use an alternative contact method. Never just say “Error occurred” with no context.
4. Form Reset After Success
form.reset();
fields.forEach(field => {
field.classList.remove('border-success');
});
After successful submission, reset the form completely - both values and visual states. This makes it clear the message was sent and allows sending another message without confusion.
Security Best Practices: Protecting Your Implementation
Security isn’t optional - it’s fundamental. Here are the essential security measures for this contact form.
1. Never Expose Credentials in Client Code
This is the entire point of the proxy pattern:
// ❌ NEVER DO THIS - Exposes token to anyone
const response = await fetch(`https://api.telegram.org/bot${TOKEN}/sendMessage`);
// ✅ CORRECT - Credentials stay server-side
const response = await fetch('/api/send-telegram-message');
Your client code should have zero knowledge of bot tokens, chat IDs, or API implementation details.
2. Configure CORS Properly
In development, Access-Control-Allow-Origin: '*' is fine. In production, restrict it:
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://yojahny.dev', // Your actual domain
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
This prevents other sites from calling your endpoint and submitting spam through your bot.
3. Validate and Sanitize All Input
Never trust client input. Validate types, lengths, and patterns:
// Server-side validation
if (!data.name || typeof data.name !== 'string' || data.name.length > 50) {
return error(400, 'Invalid name');
}
// Escape HTML to prevent injection
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
HTML escaping prevents XSS (Cross-Site Scripting) attacks where malicious users inject code through form fields.
4. Implement Rate Limiting
Cloudflare provides rate limiting at the platform level, but you can add application-level limits:
// Example: Track IPs and limit to 5 requests per hour
const ipAddress = context.request.headers.get('CF-Connecting-IP');
// Implement rate limiting logic using KV storage or Durable Objects
For most contact forms, Cloudflare’s built-in DDoS protection is sufficient. Add application-level limiting if you experience abuse.
5. Environment Variable Management
- Use .env.example: Document required variables without including values
- Never commit .env: Add to .gitignore immediately
- Rotate credentials: If a token leaks, revoke and replace it
- Separate staging/production: Use different bots for different environments
6. Error Message Security
// ❌ DON'T expose implementation details
return error(500, `Telegram API failed: ${telegram.error.description}`);
// ✅ DO return generic messages
return error(500, 'Failed to send message');
Log detailed errors server-side, but return generic messages to clients. Don’t leak API structure, error codes, or internal logic.
7. HTTPS Everywhere
Cloudflare automatically provides HTTPS for all Pages projects. Never disable it. Forms should only work over HTTPS - never plain HTTP where credentials could be intercepted.
Testing Your Implementation
Before deploying to production, thoroughly test each layer of the system.
Local Development Testing
Cloudflare Functions can be tested locally using Wrangler (Cloudflare’s CLI tool):
# Install Wrangler
npm install -g wrangler
# Start local development server
npx wrangler pages dev ./dist
# Your function runs at http://localhost:8788/api/send-telegram-message
Create a .dev.vars file for local environment variables:
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz...
TELEGRAM_CHAT_ID=987654321
Note: .dev.vars should be in .gitignore - it’s only for local development.
Test the Cloudflare Function Directly
Use curl or Postman to test your function in isolation:
curl -X POST http://localhost:8788/api/send-telegram-message \
-H "Content-Type: application/json" \
-d '{
"name": "Test User",
"email": "test@example.com",
"subject": "Test Subject",
"message": "This is a test message"
}'
Expected response:
{
"success": true,
"message": "Message sent successfully"
}
Check your Telegram - you should receive the formatted message.
Test Error Handling
Verify error cases work correctly:
# Missing required field
curl -X POST http://localhost:8788/api/send-telegram-message \
-H "Content-Type: application/json" \
-d '{"name": "Test User", "email": "test@example.com"}'
# Expected: 400 Bad Request
Production Deployment Checklist
Before deploying to production:
- Environment variables configured in Cloudflare dashboard
- CORS headers restricted to your domain
- Form validation works for all fields
- Loading states display correctly
- Success message appears after submission
- Error messages appear on failure
- Form resets after successful submission
- Mobile responsive (touch targets, keyboard behavior)
- Tested on multiple browsers (Chrome, Firefox, Safari)
- Telegram messages arrive with correct formatting
- HTML escaping prevents injection attacks
Debugging Common Issues
Problem: “Server configuration error” Solution: Environment variables not set in Cloudflare dashboard - add TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID
Problem: “Failed to send message” after form submission Solution: Check Cloudflare Functions logs - likely invalid bot token or chat ID
Problem: Form submits but no Telegram message Solution: Test the function directly with curl - likely issue with Telegram API credentials
Problem: CORS error in browser console Solution: Add correct CORS headers to function response, ensure preflight handler exists
Problem: Messages arrive with HTML tags visible
Solution: Verify parse_mode: 'HTML' is set in Telegram API call
Performance Optimization: Edge Computing Benefits
One of the biggest advantages of Cloudflare Functions is their performance. Here’s what you get:
Response Time Metrics
From production monitoring on yojahny.dev:
- Function execution time: 50-150ms average (including Telegram API call)
- Edge to user: 10-30ms (local data center)
- Total perceived response: 100-200ms for user feedback
- No cold starts: V8 isolates means instant execution every time
Compare this to traditional alternatives:
- AWS Lambda: 500ms+ cold start, 200-300ms warm execution
- Email SMTP: 500-2000ms connection and send time
- Traditional backend: Varies by location, often 300-500ms minimum
Global Distribution
Cloudflare runs your function on 250+ data centers worldwide:
- User in Tokyo: Function runs in Tokyo (12ms latency)
- User in London: Function runs in London (15ms latency)
- User in São Paulo: Function runs in São Paulo (18ms latency)
Traditional hosting requires either multiple servers (expensive) or accepting high latency for distant users.
Caching Considerations
Contact form endpoints shouldn’t be cached (every submission is unique), but consider caching:
- Form HTML: Cache aggressively with revalidation
- Client-side JavaScript: Cache with versioned URLs
- Static assets: Cache forever with content hashing
Cloudflare Pages automatically handles asset caching optimally.
Bundle Size Optimization
The client-side telegram.ts module is tiny:
- Raw size: ~400 bytes
- Gzipped: ~250 bytes
- Impact on bundle: Negligible
Compare to email libraries (often 50KB+) or form service SDKs (20KB+) - this implementation adds virtually zero weight to your client bundle.
Cost Analysis: Free for Most Projects
One of the best parts of this solution? It’s completely free for the vast majority of use cases.
Cloudflare Pages Functions Pricing
Free Tier:
- 100,000 requests per day
- 10ms CPU time per request
- Unlimited bandwidth
Typical contact form usage:
- 50 submissions per day (busy portfolio) = 1,500 per month
- 1% of free tier limit
- Cost: $0
Even if you exceed the free tier:
- $0.50 per million requests
- 200,000 submissions = $0.10
- Essentially free even at massive scale
Telegram API Pricing
Cost: $0 (completely free)
- Rate limit: 30 messages per second
- No monthly limits
- No hidden fees
- No rate limit scaling costs
Cost Comparison with Alternatives
| Solution | Setup Cost | Monthly Cost (100 submissions) | Monthly Cost (1000 submissions) |
|---|---|---|---|
| This Solution | $0 | $0 | $0 |
| Formspree | $0 | $10 (after 50) | $10-$40 |
| Netlify Forms | $0 | $19 (after 100) | $19 |
| SendGrid | $0 | $15 (after 100) | $15 |
| AWS SES + Lambda | $0 | $1-$5 | $5-$10 |
| Mailgun | $0 | $35 (after 100) | $35 |
Real-world savings: By implementing this solution instead of Formspree, I saved $120/year on my portfolio alone. For agencies managing 10+ client sites, savings exceed $1,200/year.
Hidden Costs to Consider
Time investment:
- Initial setup: 1-2 hours (following this guide)
- Testing and deployment: 30 minutes
- Maintenance: ~0 hours (serverless - no server maintenance)
Opportunity cost:
- Learning Cloudflare Functions: Reusable skill for other projects
- Understanding edge computing: Career-relevant knowledge
- Building custom solutions: Independence from third-party services
The initial time investment pays dividends through cost savings, technical skills, and platform independence.
Advanced Patterns: Extending the System
Once you have the basic implementation working, consider these enhancements:
1. File Upload Support
Add file attachments to contact messages:
// Client-side: Upload to Cloudflare R2 or AWS S3
const fileUrl = await uploadFile(file);
// Include URL in message
await sendTelegramMessage(name, email, subject, `${message}\n\nAttachment: ${fileUrl}`);
Telegram supports file uploads via Bot API, but it’s often easier to upload to object storage and send the link.
2. Application-Level Rate Limiting
Implement per-IP rate limiting using Cloudflare KV:
const ipAddress = context.request.headers.get('CF-Connecting-IP');
const key = `rate-limit:${ipAddress}`;
const count = await context.env.RATE_LIMIT_KV.get(key);
if (parseInt(count || '0') > 5) {
return error(429, 'Too many requests');
}
await context.env.RATE_LIMIT_KV.put(key, (parseInt(count || '0') + 1).toString(), {
expirationTtl: 3600 // 1 hour
});
3. Bot Commands for Managing Messages
Add Telegram bot commands for workflow automation:
/reply [message_id] - Quick reply to contact
/archive [message_id] - Archive message
/spam [message_id] - Mark as spam and block IP
Implement command handlers that interact with your Cloudflare Function.
4. Multiple Recipients
Support team notifications or routing:
// Route based on subject
const chatId = data.subject.toLowerCase().includes('job')
? context.env.HR_CHAT_ID
: context.env.GENERAL_CHAT_ID;
Create separate bots for different departments or use Telegram groups.
5. Message Templates
Format different message types distinctly:
function formatJobInquiry(data: JobInquiryData): string {
return `
🎯 <b>JOB INQUIRY</b>
👤 <b>Candidate:</b> ${escapeHtml(data.name)}
📧 <b>Email:</b> ${escapeHtml(data.email)}
💼 <b>Position:</b> ${escapeHtml(data.position)}
📎 <b>Resume:</b> <a href="${data.resumeUrl}">Download</a>
---
📅 <i>Sent from careers page</i>
`.trim();
}
Different forms (general contact, job applications, support requests) can have specialized formatting.
6. Webhook Integration
Add webhooks to trigger additional automation:
// After sending Telegram message, trigger webhooks
await fetch(context.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({ text: `New contact: ${data.name}` })
});
Notify Slack, Discord, or custom systems when messages arrive.
7. Analytics Tracking
Track form submissions in analytics:
// Before returning success
await fetch('https://plausible.io/api/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Contact Form Submit',
url: context.request.url,
domain: 'yojahny.dev'
})
});
Monitor submission rates, success/failure ratios, and user behavior.
Real-World Implementation: yojahny.dev Portfolio
This tutorial is based on the production contact form at yojahny.dev - a cyberpunk-themed portfolio built with Astro, Tailwind CSS, and GSAP animations.
Production Metrics (6 Months)
- Total submissions: 47 messages
- Success rate: 100% (zero failed deliveries)
- Average delivery time: 2.3 seconds (form submit to Telegram notification)
- False positives/spam: 2 messages (4.2%) - easily identified and ignored
- Cost: $0
- Downtime: 0 hours
- Maintenance time: 0 hours
User Experience Improvements
The cyberpunk theme uses Matrix-style effects and terminal aesthetics:
/* Scan line animation */
@keyframes scan-line {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
/* Glitch effect on headers */
@keyframes glitch {
0%, 100% { transform: translate(0); }
20% { transform: translate(-1px, 1px); }
40% { transform: translate(-1px, -1px); }
60% { transform: translate(1px, 1px); }
80% { transform: translate(1px, -1px); }
}
Form fields have accent borders, loading states show a spinner, and success/error messages animate in with smooth transitions. The entire experience feels cohesive with the cyberpunk theme.
Lessons Learned
- Users appreciate immediate feedback: Loading states during submission significantly reduced “did it work?” confusion
- Real-time validation prevents errors: Showing errors on blur (not on input) provides feedback without being annoying
- Mobile optimization is critical: 70% of submissions came from mobile - touch targets and keyboard behavior matter
- Spam is minimal: Only 2 spam messages in 6 months - no CAPTCHA needed
- Telegram workflow is excellent: Responding directly from phone notifications reduced response time from 6+ hours to under 2 hours
Integration with Portfolio
The contact form integrates with the portfolio’s existing systems:
- Theme system: Supports Matrix, Dark, and Light themes via Tailwind CSS classes
- Animation toggle: Respects user preference for reduced motion
- GSAP animations: Form elements animate in on scroll for visual polish
- Analytics: Tracks form submissions in Plausible Analytics for conversion monitoring
Troubleshooting Common Issues
Based on production experience, here are solutions to common problems:
Issue: Messages Not Arriving
Symptoms: Form submits successfully but no Telegram message
Diagnosis:
# Test bot directly
curl -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/sendMessage" \
-H "Content-Type: application/json" \
-d '{"chat_id": "<YOUR_CHAT_ID>", "text": "Test"}'
Solutions:
- Verify bot token is correct (check for typos, extra spaces)
- Verify chat ID is your user ID, not bot username
- Ensure you’ve sent at least one message to the bot (activate the chat)
- Check Cloudflare Functions logs for errors
Issue: CORS Errors in Browser
Symptoms: Access to fetch at '...' has been blocked by CORS policy
Solutions:
- Verify
corsHeadersare included in ALL responses (success, error, options) - Ensure
onRequestOptionshandler exists for preflight requests - Check that headers match exactly:
'Content-Type'not'content-type' - In development, allow all origins:
'Access-Control-Allow-Origin': '*'
Issue: Environment Variables Not Loading
Symptoms: Server configuration error message
Solutions:
- Verify variables are set for correct environment (Production vs Preview)
- Check variable names match exactly (case-sensitive):
TELEGRAM_BOT_TOKEN - Re-deploy after adding variables (changes require new deployment)
- For local development, use
.dev.varsfile
Issue: HTML Tags Showing in Telegram
Symptoms: Messages show <b>Name:</b> instead of Name:
Solutions:
- Verify
parse_mode: 'HTML'is set in Telegram API call - Check HTML escaping isn’t double-escaped
- Ensure Telegram supports the HTML tags you’re using (limited subset)
Issue: Form Submits Multiple Times
Symptoms: Duplicate messages in Telegram
Solutions:
- Disable submit button during submission:
submitBtn.disabled = true - Add form state tracking to prevent concurrent submissions
- Use
e.preventDefault()to stop default form behavior
Issue: Validation Not Working
Symptoms: Invalid data gets submitted
Solutions:
- Check validation runs before sending: validate all fields in submit handler
- Verify regex patterns are correct (test at regex101.com)
- Ensure validation errors are visible to users
- Add server-side validation as backup (never trust client validation alone)
Alternatives and Comparisons
This solution isn’t the only option. Here’s how it compares to alternatives:
Netlify Forms
Pros: Zero configuration for Netlify sites, built-in spam filtering Cons: $19/month after 100 submissions, locked to Netlify platform Best for: Small Netlify sites under free tier limits
Vercel Edge Functions
Pros: Similar edge architecture, good TypeScript support Cons: Smaller free tier (100k/day vs Cloudflare’s 100k/day but stricter CPU limits) Best for: Vercel deployments with existing Vercel infrastructure
AWS Lambda + SES
Pros: Powerful, enterprise-grade, flexible Cons: Complex setup, cold starts, region-specific, costs scale unpredictably Best for: Enterprise applications with existing AWS infrastructure
Discord Webhooks
Pros: Similar to Telegram, instant notifications Cons: Discord ToS unclear for bot usage, webhook URLs are credentials Best for: Developer portfolios where Discord is primary communication
Slack Webhooks
Pros: Team collaboration, rich formatting Cons: Webhook URLs expire, Slack focused on team use not personal Best for: Business sites with team response workflows
Form Services (Formspree, Form.io, Basin)
Pros: No code required, spam filtering, form management UI Cons: Monthly costs, platform lock-in, less customization Best for: Non-technical users or quick prototypes
Traditional Email (SMTP)
Pros: Universal, familiar, professional Cons: Server infrastructure, deliverability issues, slow, spam filters Best for: Enterprise applications with existing email infrastructure
Recommendation: For static site portfolios, personal projects, and small business sites, the Cloudflare Functions + Telegram approach offers the best balance of cost (free), performance (edge computing), and developer experience (simple setup).
Conclusion: Secure, Free, Instant Contact Forms
This solution solves the contact form problem for static sites elegantly:
Security: API credentials never touch client code, input validation prevents attacks, CORS protection prevents abuse
Cost: Completely free for virtually all use cases - no monthly fees, no per-message charges, no surprise bills
Performance: Edge computing provides sub-100ms response times globally, no cold starts, instant delivery
Simplicity: 2-hour setup, zero maintenance, no server infrastructure, no email configuration
Developer Experience: Clean abstractions, TypeScript support, reusable patterns, local development workflow
User Experience: Instant feedback, progressive validation, loading states, mobile-optimized
Key Takeaways
- API Proxy Pattern is essential: Cloudflare Functions hide credentials while providing controlled access
- Edge computing delivers performance: Global distribution without infrastructure complexity
- Telegram beats email for notifications: Instant delivery, rich formatting, mobile-first workflow
- Security requires layers: CORS, validation, HTML escaping, environment variables all contribute
- Cost savings are significant: Free tier eliminates $10-40/month per site in form service costs
When to Use This Approach
Perfect for:
- Developer portfolios and personal sites
- Small business websites
- Landing pages and marketing sites
- Agency client projects
- Side projects and MVPs
Consider alternatives if:
- You need email responses (not notifications)
- Form submissions require complex workflows
- You’re in a regulated industry requiring email audit trails
- Non-technical stakeholders need form management UI
- Your team already uses Netlify Forms or similar
Next Steps: Implement Your Contact Form
Ready to build your own? Follow these steps:
- Create Telegram bot - 5 minutes with BotFather
- Set up Cloudflare Function - Copy the production code from this guide
- Configure environment variables - Add bot token and chat ID in dashboard
- Build contact form - Use the form structure and validation patterns
- Test thoroughly - Local development, then production deployment
- Monitor and iterate - Watch Cloudflare logs and Telegram messages
Continue Learning
Want to see this implementation live? Check out:
- My portfolio contact page - Production implementation with cyberpunk theme
- yojahny.dev project - Technical deep-dive on the portfolio architecture
Interested in working together? Send me a message using this exact contact form implementation - I typically respond within 24 hours.
Share Your Implementation
Built something cool with this guide? I’d love to see it! Connect with me:
- LinkedIn - Professional network and updates
- GitHub - Code repositories and projects
- Contact form - Direct message via Telegram integration
Stay tuned for more tutorials on serverless architecture, edge computing, JAMstack development, and building production-ready web applications that scale.