Back to blog

Build a Telegram Mini App Using Pinata’s Files API - No Database!

Build a Telegram Mini App Using Pinata’s Files API - No Database!

Paige Jones

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.

  1. Add BotFather to your Telegram
  2. Enter the command /newbot
  3. Choose and enter a name and username for your bot
  4. Store the access token provided. Note: you will need this later.
  5. Enter command /newapp
  6. Select the bot you just created.
  7. Enter the prompts given to you for the apps name, description, and photo.
  8. 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 and gin packages.
  • Validate Telegram's initData using the init-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 App initData, 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:

  1. File Upload with Metadata
    • Upload Process: Sends the file as multipart form data to Pinata, including a keyvalues JSON field that maps the chatID to true.
    • Purpose: This association allows easy querying to determine if a file is linked to a particular chat.
  2. 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 new chatID to the existing file’s keyvalues, 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.

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

  1. Install Ngrok if you haven’t already:
    • Visit the Ngrok Download Page and follow the installation instructions for your operating system
  2. 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.

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

!https://prod-files-secure.s3.us-west-2.amazonaws.com/9cc5b8bb-075e-4435-b1fb-c3d115d21d00/d0a9f932-c5db-4658-9d9e-fcbafdb5b003/Screenshot_2024-11-21_at_5.16.40_PM.png

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'sWEB_APP_URL environment variable to include the deployed frontend URL.
  • 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.

This feature allows you to inspect network requests, view console logs, and troubleshoot issues directly within the Telegram environment.

Full Code Reference

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!

Subscribe to paid plan image

Share this post:

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.