Back to blog
Build a Telegram Mini App Using Pinata’s Files API - No Database!
Telegram Mini Apps (TMAs) are fully-featured applications that run directly within the Telegram platform, combining the capabilities of bots with the flexibility of custom interfaces. In this guide, we'll walk you through creating a complete, full-stack TMA that includes user authentication and file storage using Pinata's File API and key-value pairs to eliminate the need for a traditional database. This tutorial will be comprised of two parts, building a backend Golang server and creating a React frontend interface. By the end of this demo, you will have a fully authenticated mini app that allows users to upload and share images within a chat.
Prerequisites
- Basic knowledge of Go (Download).
- Basic knowledge of Node.js and Typescript. (Download)
- Telegram account and application installed on Desktop or Mobile. (Desktop preferred for development process).
- Ngrok installed on your machine (Download Ngrok)
- GitHub account (Sign up here)
- Pinata JWT token (Sign up here for free)
- Once you have signed up for an account, navigate to the API Keys tab on the left side of the dashboard. Generate a new token and store it someplace secure.
- Pinata Gateway (Sign up here for free)
- A gateway is automatically generated when you sign up for a Pinata account. Navigate to the Gateways tab on the left side of the dashboard. Copy the domain listed and store it someplace handy.
Before you start: Create a Telegram Mini App with BotFather
The first step is to create a new Telegram bot and mini app using the BotFather bot.
- Add BotFather to your Telegram
- Enter the command
/newbot
- Choose and enter a name and username for your bot
- Store the access token provided. Note: you will need this later.
- Enter command
/newapp
- Select the bot you just created.
- Enter the prompts given to you for the apps name, description, and photo.
- You will be asked for the Web App URL. Pause here, we will come back to this later in the tutorial.
Part 1: Golang Server
In this section, we’ll create a simple Go server that validates user initData
from the Telegram Mini App client and handles file upload and retrieval functionality using Pinata’s Files API. When a user launches the mini app within a chat, Telegram provides initData
to the mini app. This initData
contains details about the user and the chat session. The mini app client can send this data to our Go server, where we authenticate the user using the Go Telegram SDK init-data-golang
.
Objectives
By the end of this section, you will:
- Set up a Go server using the
net/http
andgin
packages. - Validate Telegram's
initData
using theinit-data-golang
package. - Upload images to Pinata using the Files API.
- Generate signed URLs using the Files API.
- List files associated with the chat instance using the Files API.
Step 1: Set Up Your Go Project
1. Create a New Project Directory
Begin by creating a directory for your project and navigating into it:
mkdir telegram-go-server
cd telegram-go-server
This directory will house all your server-related files and folders.
2. Initialize a New Go Module
Initialize your Go module to manage dependencies and ensure proper versioning:
go mod init github.com/<YOUR_GITHUB_USERNAME>/miniapp-server
Replace <YOUR_GITHUB_USERNAME>
with your GitHub username or organization name.
This command creates a go.mod
file, which tracks your project's dependencies.
Step 2: Install Required Packages
Install the necessary Go packages for building the server:
go get github.com/joho/godotenv
go get github.com/gin-contrib/cors
go get github.com/gin-gonic/gin
go get github.com/telegram-mini-apps/init-data-golang
Why These Packages?
godotenv
: Manages environment variables from a.env
file, allowing you to keep sensitive data like API keys secure.gin
: A high-performance HTTP web framework for Go, facilitating rapid development of web applications.cors
: Middleware for managing Cross-Origin Resource Sharing, which controls how your server handles requests from different origins.init-data-golang
: A library specifically designed to validate Telegram Mini AppinitData
, ensuring secure and reliable authentication.
Step 3: Set Up Environment Variables
Environment variables store sensitive data securely, preventing hardcoding secrets into your codebase. Create a .env
file in your project directory:
touch .env
Add the following environment variables to the .env
file:
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN # You received this from BotFather
PINATA_JWT=YOUR_JWT_TOKEN # Available in the Pinata Dashboard
PINATA_GATEWAY=YOUR_GATEWAY # Available in the Pinata Dashboard
WEB_APP_URL=* # Replace with deployed frontend URL later
Replace YOUR_BOT_TOKEN
, YOUR_JWT_TOKEN
, and YOUR_GATEWAY
with your actual tokens from Telegram BotFather and Pinata.
Step 4: Create the Main File
Create the main entry point for your server:
touch main.go
Open main.go
in your favorite text editor and add the following code:
package main
import (
"log"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables from .env file
err := godotenv.Load()
if err != nil {
log.Println("Error loading .env file")
}
// Ensure the Telegram bot token is set
telegramBotToken := os.Getenv("TELEGRAM_BOT_TOKEN")
if telegramBotToken == "" {
log.Fatal("TELEGRAM_BOT_TOKEN is not set")
}
// Configure CORS settings
corsConfig := cors.Config{
AllowOrigins: []string{os.Getenv("WEB_APP_URL")},
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"},
AllowCredentials: true,
}
// Create a Gin router with default middleware
r := gin.Default()
r.Use(cors.New(corsConfig))
//TODO: Add routes here
// Start the server
port := ":8080"
log.Printf("Server is running on port %s\\n", port)
if err := r.Run(port); err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
Step 5: Run the Server
With your main file configured, you can now run your server to ensure everything is set up correctly:
go run main.go
You should see the following output in your terminal:
Server is running on port :8080
Congratulations! 🎉 Your server is now operational and ready to handle incoming requests.
Step 6: Organize Your Project
Create the following folders to structure your codebase effectively:
mkdir routes dto services
Within each directory, add the necessary files to adhere to the following structure:
dto
├── auth.go
├── files.go
routes
├── auth
│ ├── controller.go
│ └── handler.go
├── files
│ ├── controller.go
│ └── handler.go
└── routes.go
services
├── auth.go
└── files.go
main.go
Directory Structure Explained:
dto/
(Data Transfer Objects): Contains structures that define the shape of data exchanged between the client and server. This ensures consistency and clarity in data handling.routes/
: Manages the routing logic, organizing different API endpoints into modular components for authentication and file management.services/
: Encapsulates the business logic, handling tasks such as user authentication and file operations with Pinata.
Step 7: Implement Authentication Logic
Authentication is a critical component of your Mini App, ensuring that only authorized users can access and manipulate data. We'll validate the initData
received from Telegram and manage user sessions.
1. Define the Auth DTO
Create a Data Transfer Object (DTO) to handle authentication requests and responses. This DTO structures the data exchanged between the frontend client and the backend server. Add the following to dto/auth.go
:
package dto
import tgData "github.com/telegram-mini-apps/init-data-golang"
type AuthRequest struct {
InitData string `json:"initData"`
IsMocked bool `json:"isMocked"`
}
type AuthOutput struct {
User tgData.User `json:"user"`
ChatID string `json:"chat_id"`
Message string `json:"message"`
}
2. Build the Auth Service
The auth service encapsulates the business logic required to authenticate users based on the initData
provided by Telegram. It handles validation, parsing, and the optional mocking of data for testing.
For testing purposes, we’ll include an isMocked
flag that allows us to return dummy Telegram data. The server will respond with the parsed user information and chatID
, which can then be consumed by the frontend client.
Add the following to services/auth.go
:
package services
import (
"errors"
"fmt"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
tgData "github.com/telegram-mini-apps/init-data-golang"
)
type AuthService struct{}
func NewAuthService() *AuthService {
return &AuthService{}
}
func (s *AuthService) Authenticate(c *gin.Context, initData *string, isMocked bool) (dto.AuthOutput, error) {
if initData == nil && !isMocked {
return dto.AuthOutput{}, errors.New("initData is required")
}
// Get the Telegram Bot Token from environment variables
telegramBotToken := os.Getenv("TELEGRAM_BOT_TOKEN")
if telegramBotToken == "" {
return dto.AuthOutput{}, errors.New("telegram bot token is not set")
}
// Handle mocked data for testing
if isMocked {
mockUserData := tgData.User{
ID: 123456789,
FirstName: "Test",
LastName: "User",
Username: "testuser",
PhotoURL: "<https://www.gravatar.com/avatar>",
}
response := dto.AuthOutput{
User: mockUserData,
ChatID: "123456789",
Message: "Using mocked data",
}
return response, nil
}
// Define expiration time for initData (e.g., 24 hours)
expiration := 24 * time.Hour
if initData != nil {
// Validate the initData with the Telegram Bot Token and expiration time
err := tgData.Validate(*initData, telegramBotToken, expiration)
if err != nil {
log.Println("Error validating initData:", err)
return dto.AuthOutput{}, errors.New("invalid initData")
}
// Parse the initData to get user data
initDataParsed, err := tgData.Parse(*initData)
if err != nil {
log.Println("Error parsing initData:", err)
return dto.AuthOutput{}, errors.New("failed to parse initData")
}
// Respond with the parsed initData
response := dto.AuthOutput{
User: initDataParsed.User,
ChatID: fmt.Sprint(initDataParsed.ChatInstance),
Message: "Using parsed data",
}
return response, nil
}
return dto.AuthOutput{}, errors.New("invalid initData")
}
Note: Ensure that your dto
file imports reflect your module path in your services
package and other files referencing it.
3. Add Auth Routes
Define the route logic in routes/auth/controller.go
. This controller bridges HTTP requests with the authentication service, handling incoming authentication requests and sending appropriate responses.
Add the following to routes/auth/controller.go
:
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
"github.com/paigexx/telegram-go-server/services"
)
type Handler struct {
service services.AuthService
}
func newHandler(authService services.AuthService) *Handler {
return &Handler{
service: authService,
}
}
func (h Handler) Authenticate(c *gin.Context) {
input := dto.AuthRequest{}
if err := c.ShouldBind(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form data"})
return
}
result, err := h.service.Authenticate(c, &input.InitData, input.IsMocked)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"})
return
}
c.JSON(http.StatusOK, result)
}
4. Create the Auth Handler
Add the following to routes/auth/handler.go
. This file initializes the authentication routes and associates them with their respective handlers.
package auth
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
func NewHandler(r *gin.RouterGroup) {
auth := r.Group("/auth")
service := services.NewAuthService()
h := newHandler(*service)
auth.POST("", h.Authenticate)
}
Step 8. Apply the routes
1. Add routes/routes.go
Locate the routes/routes.go
file and add the following code to apply the authentication routes:
package routes
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/miniapp-server/routes/auth"
)
func ApplyRoutes(r *gin.Engine) {
api := r.Group("/")
auth.NewHandler(api)
}
2. Ensure Routes are Applied in main.go
Confirm that main.go
applies the routes by including the following import and function call:
import (
// ... other imports
"github.com/paigexx/miniapp-server/routes"
)
func main() {
// ... existing code
// Apply routes
routes.ApplyRoutes(r)
// ... existing code
}
This step integrates all defined routes into the Gin router, enabling the server to handle incoming requests appropriately.
Step 9: Create the Files Logic
To enable file uploads, signed URL generation, and file listing, we'll integrate Pinata’s Files API into the Go server. This involves creating services, DTOs, and routes for managing files.
1. Create the Files DTO
The Data Transfer Object (DTO) defines the structure for file-related data. Add the following to dto/files.go
:
package dto
type FileUploadRequest struct {
File string `json:"file"`
TelegramID string `json:"tg_id"`
}
//responses from Pinata Files API
type FileUploadResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
CID string `json:"cid"`
CreatedAt string `json:"created_at"`
Size int `json:"size"`
NumberOfFiles int `json:"number_of_files"`
MimeType string `json:"mime_type"`
UserID string `json:"user_id"`
KeyValues map[string]string `json:"keyvalues"`
IsDuplicate *bool `json:"is_duplicate"`
} `json:"data"`
}
type File struct {
ID string `json:"id"`
Name string `json:"name"`
CID string `json:"cid"`
Size int `json:"size"`
NumberOfFiles int `json:"number_of_files"`
MimeType string `json:"mime_type"`
GroupID string `json:"group_id"`
KeyValues map[string]string `json:"keyvalues"`
CreatedAt string `json:"created_at"`
}
type ListFilesResponse struct {
Data struct {
Files []File `json:"files"`
NextPageToken string `json:"next_page_token"`
} `json:"data"`
}
type UpdateFileResponse struct {
Data File `json:"data"`
}
type SignedURLResponse struct {
Data string `json:"data"`
}
2. Create the Files Service
The files service encapsulates the business logic required to interact with Pinata’s Files API, handling tasks such as uploading files, generating signed URLs, and listing files associated with specific chat sessions.
Add the following to services/files.go
:
a. Upload Files to Pinata
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/dto"
)
type FilesService struct{}
func NewFilesService() *FilesService {
return &FilesService{}
}
func (s *FilesService) Upload(c *gin.Context, file multipart.File, fileName string, chatID string) (string, error) {
// Create a buffer to hold the multipart form data for Pinata
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Create a form file field named "file"
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return "", fmt.Errorf("error creating form file: %s", err)
}
// Copy the uploaded file data to the form file field
_, err = io.Copy(part, file)
if err != nil {
return "", fmt.Errorf("error copying file data: %s", err)
}
// Create a map with your key-value pairs
keyvaluesData := map[string]interface{}{
fmt.Sprintf("%v", chatID): "true",
}
// Marshal the map into a JSON string
keyvaluesJSON, err := json.Marshal(keyvaluesData)
if err != nil {
return "", fmt.Errorf("error marshaling keyvalues: %s", err)
}
// Write the JSON string to the form field
err = writer.WriteField("keyvalues", string(keyvaluesJSON))
if err != nil {
return "", fmt.Errorf("error writing keyvalues field: %s", err)
}
// Close the writer to finalize the multipart form data
err = writer.Close()
if err != nil {
return "", fmt.Errorf("error closing writer: %s", err)
}
// Continue with the rest of your code...
// Create a new POST request to Pinata's file upload endpoint
url := "<https://uploads.pinata.cloud/v3/files>"
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
// Set the appropriate headers, including your Pinata JWT token
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) // Replace with your actual token
// Send the request to Pinata
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
// Read the response from Pinata
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error uploading file: %s", responseBody)
}
var pinataResp dto.FileUploadResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
// Check if the file is a duplicate, if so update the metadata with the chatID
if pinataResp.Data.IsDuplicate != nil && *pinataResp.Data.IsDuplicate {
s.UpdateMetadata(pinataResp.Data.ID, chatID)
}
return pinataResp.Data.ID, nil
}
func (s *FilesService) UpdateMetadata(fileId string, chatId string) (string, error) {
url := fmt.Sprintf(`https://api.pinata.cloud/v3/files/%s`, fileId)
// Create payload with the new keyvalues
payloadData := map[string]interface{}{
"keyvalues": map[string]string{
fmt.Sprintf("%v", chatId): "true",
},
}
payloadBytes, err := json.Marshal(payloadData)
if err != nil {
return "", fmt.Errorf("error marshalling payload: %s", err)
}
// Create the PUT request
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
req.Header.Set("Content-Type", "application/json")
// Send the PUT request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
// Read the response
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error updating metadata: %s", responseBody)
}
var updateResp dto.UpdateFileResponse
err = json.Unmarshal(responseBody, &updateResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
return updateResp.Data.ID, nil
}
The Upload
function manages file uploads to Pinata’s Files API and associates each file with specific chat instances through metadata. It performs two main tasks:
- File Upload with Metadata
- Upload Process: Sends the file as multipart form data to Pinata, including a
keyvalues
JSON field that maps thechatID
totrue
. - Purpose: This association allows easy querying to determine if a file is linked to a particular chat.
- Upload Process: Sends the file as multipart form data to Pinata, including a
- Duplicate Handling and Metadata Update
- Duplicate Detection: Checks Pinata’s response to identify if the file has already been uploaded.
- Metadata Update: If duplicate, it calls the
UpdateMetadata
function to add the newchatID
to the existing file’skeyvalues
, avoiding redundant uploads and maintaining accurate associations.
b. Generate Signed URLs
func (s *FilesService) GetSignedUrl(c *gin.Context, cid string) (string, error) {
url := `https://api.pinata.cloud/v3/files/sign`
gateway := os.Getenv("PINATA_GATEWAY")
// Construct the full URL as per the required format
fileURL := fmt.Sprintf("%s/files/%s", gateway, cid)
payloadData := map[string]interface{}{
"url": fileURL,
"method": "GET",
"date": time.Now().Unix(), // Current Unix timestamp
"expires": 3600, // URL valid for 1 hour
}
payloadBytes, err := json.Marshal(payloadData)
if err != nil {
return "", fmt.Errorf("error marshalling payload: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return "", fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
req.Header.Set("Content-Type", "application/json") // Set the Content-Type header
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error getting signed URL: %s", responseBody)
}
var pinataResp dto.SignedURLResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return "", fmt.Errorf("error unmarshaling response: %s", err)
}
return pinataResp.Data, nil
}
GetSignedUrl
, generates a signed URL for accessing a file stored with Pinata. You can specify the expiration time for the link within payload data.
c. List Files
func (s *FilesService) List(c *gin.Context, chatID string, pageToken string) (dto.ListFilesResponse, error) {
url := fmt.Sprintf(`https://api.pinata.cloud/v3/files?pageToken=%v&metadata[%v]=true&limit=5`, pageToken, chatID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error creating request: %s", err)
}
jwt := os.Getenv("PINATA_JWT")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) // Replace with your actual token
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error sending request: %s", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error reading response: %s", err)
}
if resp.StatusCode != http.StatusOK {
return dto.ListFilesResponse{}, fmt.Errorf("error listing files: %s", responseBody)
}
var pinataResp dto.ListFilesResponse
err = json.Unmarshal(responseBody, &pinataResp)
if err != nil {
return dto.ListFilesResponse{}, fmt.Errorf("error unmarshaling response: %s", err)
}
return pinataResp, nil
}
List
constructs a request to Pinata’s Files API, filtering files by chatID
and handling pagination with pageToken
. The limit
parameter restricts the number of files returned per request (set to 5 in this example
3. Add File Routes
Define the routes in routes/files/controller.go
. This controller manages the endpoints related to file operations, such as uploading files, listing files, and generating signed URLs.
Add the following to routes/files/controller.go
:
package files
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
type Handler struct {
service services.FilesService
}
func newHandler(filesService services.FilesService) *Handler {
return &Handler{
service: filesService,
}
}
func (h Handler) Upload(c *gin.Context) {
err := c.Request.ParseMultipartForm(10 << 20) // 10MB
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Error parsing form data"})
return
}
// Retrieve the file from the form data
file, handler, err := c.Request.FormFile("file")
if err != nil {
http.Error(c.Writer, "Error retrieving file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
chatID := c.Request.FormValue("chat_id")
id, err := h.service.Upload(c, file, handler.Filename, chatID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": id})
}
func (h Handler) List(c *gin.Context) {
chatID := c.Param("chat_id")
pageToken := c.Query("pageToken")
files, err := h.service.List(c, chatID, pageToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, files)
}
func (h Handler) GetSignedUrl(c *gin.Context) {
cid := c.Param("cid")
url, err := h.service.GetSignedUrl(c, cid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
4. Create the Files Handler
Add the following to routes/files/handler.go
. This file initializes the file-related routes and associates them with their respective handlers.
package files
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/services"
)
func NewHandler(r *gin.RouterGroup) {
files := r.Group("/files")
service := services.NewFilesService()
h := newHandler(*service)
files.POST("", h.Upload)
files.GET(":chat_id", h.List)
files.GET("signedUrl/:cid", h.GetSignedUrl)
}
5. Register the File Routes
Ensure that both auth and file routes are registered by updating routes/routes.go
:
package routes
import (
"github.com/gin-gonic/gin"
"github.com/paigexx/telegram-go-server/routes/auth"
"github.com/paigexx/telegram-go-serverr/routes/files"
)
func ApplyRoutes(r *gin.Engine) {
api := r.Group("/")
auth.NewHandler(api)
files.NewHandler(api)
}
Step 10. Deploy Your Server
While local development is essential, deploying your server ensures it’s accessible to Telegram and your frontend application. Render is a good choice for deploying Go applications seamlessly. Follow these steps to deploy your server:
1. Initialize a Git Repository
Commit your changes:
git commit -m "Initial commit"
Add all your files to the repository:
git add .
In your project directory, initialize a Git repository:
git init
2. Push to GitHub
- Create a New Repository:
- Log in to your GitHub account.
- Click on "New" to create a new repository.
- Name your repository (e.g.,
miniapp-server
) and provide a description if desired. - Choose the repository visibility (public or private).
- Click "Create repository".
- Link your local repository to GitHub:
git remote add origin <https://github.com/username/repository-name.git>
Replace username
and repository-name
with your GitHub username and the name of the repository you just created.
- Push Your Code:
git push -u origin main
3. Set Up a Render Account
- Sign Up/Login:
- Go to Render and sign up for a new account or log in if you already have one.
- Create a New Web Service:
- Once logged in, click on the "New +" button in the dashboard.
- Select "Web Service" from the dropdown options.
4. Connect GitHub Repository
- Authorize Render:
- During the setup process, Render will prompt you to connect your GitHub account. Follow the authorization steps to grant Render access to your repositories.
- Select Repository:
- Choose the repository you pushed earlier (e.g.,
miniapp-server
) from the list of available repositories.
- Choose the repository you pushed earlier (e.g.,
5. Configure Deployment Settings
In most cases, Render will handle the deployment settings. However, if you need to manually add them these are the details:
- Name: Enter a name for your service.
- Region: Select the nearest region.
- Branch: Use
main
or specify your branch. - Environment: Select the environment, typically "Docker" or "Native".
Start Command: Specify how to start your server, e.g.:
./main
Build Command: For a Go server, use:
go build -o main .
6. Add Environment Variables
- In the same deployment configuration, scroll to "Environment Variables".
- Add the variables your necessary:
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
# Create a bot & app with BotFather: <https://core.telegram.org/bots#6-botfather>
PINATA_JWT=your_pinata_jwt
PINATA_GATEWAY=your_pinata_gateway
# Sign up for a free account: <https://app.pinata.cloud>
WEB_APP_URL=*
# The URL of your frontend app, you can use ngrok url or a deployed frontend URL later.
7. Test Your Deployment
Once deployed, Render provides a live URL for your service. Follow these steps to test your server:
- Access the Live URL: Open the provided Render URL in your browser or use tools like
curl
to interact with your endpoints. - Verify Responses:
- Successful Authentication: Should return a JSON object containing user details,
chat_id
, and a success message.
- Successful Authentication: Should return a JSON object containing user details,
- This step confirms that your server is correctly handling authentication and interacting with Pinata’s Files API as expected.
Example Test with curl
:
curl <https://your-service-name.onrender.com/auth> \\
-X POST \\
-H "Content-Type: application/json" \\
-d '{"initData":"test_init_data","isMocked":true}'
Replace your-service-name.onrender.com
with your actual Render service URL and your_init_data_here
with valid initData
from Telegram.
Congrats!🎉
You just successfully built and deployed a Go server to support file storage and user authentication. In the next part of this series, we'll focus on building the frontend client that interacts with this backend, completing the full-stack application.
Part 2: Frontend Telegram Mini App Client
In this section, we’ll build the frontend React application that interacts with the Golang backend server. The frontend will handle user authentication, file uploads, and displaying uploaded files within the Telegram Mini App interface.
Objectives
By the end of this section, you will:
- Create a React project using TypeScript.
- Implement user authentication by handling Telegram's
initData
. - Upload files to Pinata via the backend server.
- List and view uploaded files within the Telegram Mini App.
Step 1: Create Your React Project
Begin by setting up a new React project with TypeScript support. Open your terminal and run the following command in the target directory where you want to create your frontend client:
npx create-react-app your_app_name --template typescript
cd your_app_name
Replace your_app_name
with your desired application name.
This command initializes a new React project with TypeScript configuration, setting up all necessary files and dependencies.
Step 2: Install Required Dependencies
Install the necessary packages for integrating with Telegram, managing environment variables, handling API requests, and enhancing UI components
npm install @telegram-apps/sdk-react @telegram-apps/telegram-ui dotenv @tanstack/react-query pinata @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons
Step 3: Set Up Environment Variables
Create a .env
file in your project directory:
touch .env
Add the following environment variables to the .env
file:
REACT_APP_SERVER_URL=your_server_url
# Your deployed server URL from Part 1.
REACT_APP_MOCK_TELEGRAM=true
# Set to false to use Telegram init data
Replace your_server_url
with the URL of your deployed Golang server from Part 1.
Step 4: Create a Public URL for Telegram
During development, your React application runs on localhost
, which isn't accessible to Telegram. To allow Telegram to communicate with your local server, you need to create a public URL that tunnels into your localhost
. Ngrok is a popular tool for this purpose.
- Install Ngrok if you haven’t already:
- Visit the Ngrok Download Page and follow the installation instructions for your operating system
- Configure Telegram BotFather:This configuration allows Telegram to send
initData
to your frontend application during development.- Go back to the Telegram chat with BotFather.
- Enter the command to update your Mini App's Web App URL.
- Provide the Ngrok URL you obtained earlier.
Retrieve the Forwarded Address:After running the above command, Ngrok will provide a forwarded address similar to:
<https://ca57-159-192-20-21.ngrok-free.app>
Run Ngrok to Create a Public URL:Open a new terminal tab or window and run the following command:
ngrok http <http://localhost:3000>
This command creates a secure tunnel to your local development server running on port 3000.
Note: Remember that Ngrok URLs are temporary and change each time you restart Ngrok unless you have a paid plan with reserved URLs.
Step 5: Update the index.tsx
The index.tsx
file is the entry point for your React application. We'll need to wrap our app with several providers to utilize the installed dependencies effectively. Replace your current index.tsx
code with the following:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AppRoot } from '@telegram-apps/telegram-ui';
import './index.css';
import '@telegram-apps/telegram-ui/dist/styles.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const queryClient = new QueryClient()
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AppRoot>
<App/>
</AppRoot>
</QueryClientProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: <https://bit.ly/CRA-vitals>
reportWebVitals();
Explanation:
AppRoot
: ensures that UI components are styled according to Telegram's design.QueryClientProvider
: wraps the application to enable data fetching and caching capabilities.
Step 6: View Your Application in Telegram
Since we want to debug and work locally within Telegram, you'll need to access your application through Telegram's interface using the public URL created by Ngrok.
- Access the Application via Telegram:
- Open Telegram and navigate to your bot.
- Click on the Direct Link to your Mini App. This link was provided by BotFather during the Mini App creation.
- Your application should load within the Telegram application window.
Ensure Your Application is Running:In your project directory, start your React application:
npm run start
This command starts the development server on http://localhost:3000
. You should see the following output:
Compiled successfully!
You can now view your_app_name in the browser.
Local: <http://localhost:3000>
On Your Network: <http://192.168.x.x:3000>
Important: Ensure that you have set the Web App URL in BotFather to your Ngrok URL and that your React application is running on localhost:3000
. This setup allows Telegram to communicate with your frontend application during development.
Step 7: Handle User Authentication
User authentication ensures that only authorized users can access and interact with your Mini App. We'll manage authentication by handling Telegram's initData
and communicating with our Golang backend server.
Locate the App.tsx
file in your application and replace the contents with the following code:
import { useEffect, useState } from "react";
import { useLaunchParams } from "@telegram-apps/sdk-react";
import { useTelegramMock } from "./hooks/useMockTelegramInitData";
import "./App.css";
import '@telegram-apps/telegram-ui/dist/styles.css';
import UploadFile from "./components /UploadFiles";
import Files from "./components /Files";
import { Spinner, Title } from "@telegram-apps/telegram-ui";
function App() {
const {initDataRaw} = useLaunchParams() || {};
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [chatId, setChatId] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useTelegramMock();
const isMocked = process.env.REACT_APP_MOCK_TELEGRAM === "true";
useEffect(() => {
if (initDataRaw) {
const fetchData = async () => {
try {
setIsLoading(true);
const payload: any = {
initData: initDataRaw,
isMocked: isMocked,
};
const serverUrl = process.env.REACT_APP_SERVER_URL;
const response = await fetch(
`${serverUrl}/auth`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
throw new Error(
`Server error: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
setIsAuthenticated(true);
setChatId(data.chat_id);
} catch (error) {
setErrorMessage(`An error occurred during authentication: ${error}`);
} finally {
setIsLoading(false);
}
};
fetchData();
} else {
setIsLoading(false);
}
}, [initDataRaw]);
return (
<div style={{width: "100vw", height: "100vh", textAlign: "center", backgroundImage: `url(${"https://refactor_gateway_create.dev-mypinata.cloud/ipfs/bafkreicsb4n2y2evpb7xcqel3bsej2omos4itx3v56sfzmjtp4fh2gzpru"})`,
backgroundSize: 'cover',
overflow: 'hidden',
backgroundRepeat: 'no-repeat', }}>
<div style={{padding: "15px", backgroundColor: "black", display: "flex", alignItems: "center", justifyContent: "space-between", textAlign: "left"}}>
<Title level="1" weight="1">
PinnieBox
</Title>
{isAuthenticated && <UploadFile chatId={chatId}/>}
</div>
{isLoading ? ( <Spinner size="l" /> )
: isAuthenticated ? (<Files chatId={chatId}/>)
: errorMessage ? ( <p>{errorMessage}</p>)
: (<p>User is not authenticated</p>
)}
</div>
);
}
export default App;
The App.tsx
component manages user authentication via Telegram. Upon successful authentication, it will display the file upload and management interface; otherwise, it shows an error message. The chatID
returned from the Go server is captured and plays a crucial role in the file upload process. useTelegramMock()
is a ****custom hook that simulates the Telegram environment for local development. We'll create this hook in the next step.
Step 8: Create the useTelegramMock()
Hook
To simulate the Telegram environment during local development, we'll use a custom React hook named useTelegramMock()
. This hook generates mock initData
when running in development mode, allowing you to test the frontend without relying on real Telegram data.
Add the Hook Code:Open useMockTelegramInitData.ts
in your text editor and add the following code:
/* eslint-disable camelcase */
import { mockTelegramEnv, parseInitData, retrieveLaunchParams } from "@telegram-apps/sdk-react";
/**
* Mocks
Telegram environment in development mode.
*/
export function useTelegramMock(): void {
if (process.env.NODE_ENV !== "development") return;
let shouldMock: boolean;
try {
retrieveLaunchParams();
shouldMock = !!sessionStorage.getItem("____mocked");
} catch (e) {
shouldMock = true;
}
if (shouldMock) {
const randomId = Math.floor(Math.random() * 1000000000);
const initDataRaw = new URLSearchParams([
[
"user",
JSON.stringify({
id: randomId,
first_name: "Andrew",
last_name: "Rogue",
username: "rogue",
language_code: "en",
is_premium: true,
allows_write_to_pm: true,
}),
],
["hash", "89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31"],
["auth_date", "1716922846"],
["start_param", "debug"],
["chat_type", "sender"],
["chat_instance", "8428209589180549439"],
]).toString();
mockTelegramEnv({
themeParams: {
accentTextColor: "#6ab2f2",
bgColor: "#17212b",
buttonColor: "#5288c1",
buttonTextColor: "#ffffff",
destructiveTextColor: "#ec3942",
headerBgColor: "#17212b",
hintColor: "#708499",
linkColor: "#6ab3f3",
secondaryBgColor: "#232e3c",
sectionBgColor: "#17212b",
sectionHeaderTextColor: "#6ab3f3",
subtitleTextColor: "#708499",
textColor: "#f5f5f5",
},
initData: parseInitData(initDataRaw),
initDataRaw,
version: "7.7",
platform: "tdesktop",
});
sessionStorage.setItem("____mocked", "1");
}
}
Create the Hook File:Inside the hooks
folder, create a file named useMockTelegramInitData.ts
:
touch src/hooks/useMockTelegramInitData.ts
Create a Hooks Directory:Inside your src
folder, create a new folder named hooks
:
mkdir src/hooks
The useTelegramMock
hook simulates the Telegram environment by generating mock initData
that mimics the data Telegram provides when launching a Mini App. This is particularly useful during local development and testing.
Step 9: Create the Files Query
To efficiently fetch and manage data related to files stored on Pinata, we'll use the @tanstack/react-query
library. This library simplifies data fetching, caching, and synchronization in React applications.
Add the Query Code:Open UseFiles.ts
in your text editor and add the following code:
import { useQuery } from "@tanstack/react-query";
export const useGetFiles = (chatId: string, pageToken: string) => {
const serverUrl = process.env.REACT_APP_SERVER_URL;
return useQuery({
queryKey: ["getFiles", chatId, pageToken],
queryFn: async () => {
const response = await fetch(
`${serverUrl}/files/${chatId}?pageToken=${pageToken}`,
{
method: "GET",
headers: {
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
return response.json();
},
});
};
Create the Query File:Inside the queries
folder, create a file named UseFiles.ts
:
touch src/hooks/queries/UseFiles.ts
Create a Queries Directory:Inside your hooks
folder, create a new folder named queries
:
mkdir src/hooks/queries
Step 10: Add the Missing Components
Now it’s time to create the missing components that you see in the App.tsx
. These components are responsible for uploading and listing the files that will be stored with Pinata.
a. Uploading Files
We will start with the component responsible for handling file uploads.
Add the Upload Files Code:Open UploadFiles.tsx
in your text editor and add the following code:
import { Spinner } from "@telegram-apps/telegram-ui";
import { useState, useEffect, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFileUpload,
faCheckCircle,
faTimesCircle,
} from "@fortawesome/free-solid-svg-icons";
import { useGetFiles } from "../hooks/queries/UseFiles";
interface UploadFileProps {
chatId: string;
}
const UploadFile: React.FC<UploadFileProps> = ({ chatId}) => {
const [uploadStatus, setUploadStatus] = useState<
"idle" | "uploading" | "success" | "error"
>("idle");
const fileInputRef = useRef<HTMLInputElement>(null);
const { refetch } = useGetFiles(chatId, "");
const handleFileUpload = async (file: File | undefined) => {
if (!file) return;
const serverUrl = process.env.REACT_APP_SERVER_URL;
try {
setUploadStatus("uploading");
const formData = new FormData();
formData.append("file", file);
formData.append("chat_id", chatId);
const response = await fetch(`${serverUrl}/files`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
console.log("File uploaded successfully:", data);
setUploadStatus("success");
// Refetch files after successful upload
await refetch();
} catch (error) {
console.error("Error during file upload:", error);
setUploadStatus("error");
}
};
useEffect(() => {
if (uploadStatus === "success" || uploadStatus === "error") {
const timer = setTimeout(() => {
setUploadStatus("idle");
}, 3000);
return () => clearTimeout(timer);
}
}, [uploadStatus]);
return (
<div style={{ textAlign: "center" }}>
{uploadStatus === "idle" && (
<div
onClick={() => fileInputRef.current?.click()}
style={{ cursor: "pointer" }}
>
<FontAwesomeIcon icon={faFileUpload} size="2x" />
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => handleFileUpload(e.target.files?.[0])}
/>
</div>
)}
{uploadStatus === "uploading" && <Spinner size="l" />}
{uploadStatus === "success" && (
<div>
<FontAwesomeIcon icon={faCheckCircle} size="2x" color="green" />
</div>
)}
{uploadStatus === "error" && (
<div>
<FontAwesomeIcon icon={faTimesCircle} size="2x" color="red" />
</div>
)}
</div>
);
};
export default UploadFile;
Create the Upload Files Component:Inside the components
folder, create a file named UploadFiles.tsx
:
touch src/components/UploadFiles.tsx
The UploadFiles.tsx
component is responsible for sending a selected file to the Go server's POST /files
endpoint. Along with the file, it includes the associated chatId
, which is extracted from the Telegram init data. This init data was validated on the Go server to ensure security and authenticity before processing.
b. Listing Files
Next, we will create the component responsible for listing and navigating through uploaded files.
Add the Files List Code:Open Files.tsx
in your text editor and add the following code:
import { Button } from "@telegram-apps/telegram-ui";
import FileModal from "./FileModal";
import { useGetFiles } from "../hooks/queries/UseFiles";
import { useState } from "react";
interface FilesProps {
chatId: string;
}
const Files: React.FC<FilesProps> = ({ chatId }) => {
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageTokens, setPageTokens] = useState<string[]>([""]);
const pageToken = pageTokens[currentPage];
const { data: files, isLoading, error, isFetching } = useGetFiles(chatId, pageToken);
const handleNextPage = () => {
const nextPageToken = files?.data?.next_page_token;
if (nextPageToken) {
if (!pageTokens.includes(nextPageToken)) {
setPageTokens((prev) => [...prev, nextPageToken]);
}
setCurrentPage((prev) => prev + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 0) {
setCurrentPage((prev) => prev - 1);
}
};
const truncateFileName = (fileName: string, maxLength: number) => {
const extension = fileName.substring(fileName.lastIndexOf("."));
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."));
if (nameWithoutExt.length > maxLength) {
return `${nameWithoutExt.substring(0, maxLength)}...${extension}`;
}
return fileName;
};
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
backgroundColor: "white",
marginTop: "20px",
borderRadius: "10px",
}}
>
<Button
style={{
width: "50%",
backgroundColor: "#33f9a1",
color: "black",
}}
disabled={currentPage === 0 || isFetching}
onClick={handlePrevPage}
>
Prev
</Button>
<Button
style={{
width: "50%",
backgroundColor: "#33f9a1",
color: "black",
}}
disabled={files?.data?.files.length < 5 || isFetching}
onClick={handleNextPage}
>
Next
</Button>
</div>
<div>
{error && <p>{`Error: ${error.message}`}</p>}
{isLoading ? (
<p>Loading...</p>
) : (
<div style={{marginTop: "10px"}}>
{files?.data?.files?.map((file: any, fileIndex: number) => (
<div
key={fileIndex}
style={{
display: "flex",
marginTop: "5px",
justifyContent: "space-between",
padding: "15px",
backgroundColor: "black",
opacity: ".9",
borderRadius: "20px",
}}
>
<p>{truncateFileName(file.name, 15)}</p>
<FileModal file={file} />
</div>
))}
</div>
)}
</div>
</div>
);
};
export default Files;
Create the Files List Component:Inside the components
folder, create a file named Files.tsx
:
touch src/components/Files.tsx
c. Display File Content
Next, we will create the component that allows users to view the uploaded content in a modal.
Add the File Modal Code:Open FileModal.tsx
in your text editor and add the following code:
import { Modal, Button, Placeholder, FixedLayout } from "@telegram-apps/telegram-ui";
import { ModalHeader } from "@telegram-apps/telegram-ui/dist/components/Overlays/Modal/components/ModalHeader/ModalHeader";
import { useState } from "react";
const FileModal = ({ file }: any) => {
const [open, setOpen] = useState(false);
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [loadingUrl, setLoadingUrl] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleOpenChange = async (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
setLoadingUrl(true);
const url = await getSignedUrl(file);
if (url) {
setSignedUrl(url);
} else {
setErrorMessage("Failed to load image.");
}
setLoadingUrl(false);
} else {
setSignedUrl(null);
setErrorMessage(null);
}
};
const getSignedUrl = async (file: any) => {
const serverUrl = process.env.REACT_APP_SERVER_URL;
try {
const response = await fetch(
`${serverUrl}/files/signedUrl/${file.cid}`,
{
method: "GET",
headers: {
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(
`Failed to get signed URL: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
return data.url;
} catch (error) {
console.error("Error getting signed URL:", error);
return null;
}
};
return (
<Modal
header={<ModalHeader>{file.name}</ModalHeader>}
trigger={<Button style={{backgroundColor: "#33f9a1", color: "black"}}>View</Button>}
open={open}
onOpenChange={handleOpenChange}
>
<Placeholder>
{loadingUrl ? (
<p>Loading image...</p>
) : signedUrl ? (
<a
href={signedUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleOpenChange(false)}
>
<img
alt={file.name}
src={signedUrl}
style={{
width: '100%',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</a>
) : (
<p>{errorMessage}</p>
)}
</Placeholder>
</Modal>
);
};
export default FileModal;
Create the File Modal Component:Inside the components
folder, create a file named FileModal.tsx
:
touch src/components/FileModal.tsx
Step 11: Deploy the Frontend Application (Optional)
Deploying your React frontend application ensures that your Telegram Mini App is accessible beyond your local development environment. While this step is optional, deploying your app provides a live URL that Telegram can interact with, enabling real-world usage and testing.
Helpful Notes & Debugging:
- Update Web App URL in Telegram (optional). If you deployed your frontend application make sure to update the Web App URL in BotFather to the deployed address.
- Update Environment Variables for CORS (optional):
- If you choose to deploy your frontend, update your backend's
WEB_APP_URL
environment variable to include the deployed frontend URL.
- If you choose to deploy your frontend, update your backend's
- Debugging in Telegram:
- Enable Debug Mini Apps:
- Quickly click 5 times on the Settings icon within the Mini App interface.
- This action opens the Debug Menu.
- Enable the
Debug Mini Apps
option to access advanced debugging tools.
- Enable Debug Mini Apps:
This feature allows you to inspect network requests, view console logs, and troubleshoot issues directly within the Telegram environment.
Full Code Reference
- Server Code: Access the full server code here
- Client Code: Access the full client code here
Conclusion
Congratulations! 🎉
You've successfully built and deployed a full-stack Telegram Mini App. This application leverages the power of Go for backend operations and React for a responsive and dynamic frontend experience, all while utilizing Pinata's robust file management capabilities with the Files API!