DEV Community

Cover image for Building Telegram Mini Apps with React
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Building Telegram Mini Apps with React

Written by Emmanuel John✏️

Telegram recently launched its Mini Apps integration, enabling web developers to build a wide range of applications that allow users to seamlessly transition from chatting to shopping, gaming, or booking services without ever stepping out of the Telegram ecosystem.

Telegram Mini Apps provide the same mobile app experience as native mobile apps, solving one of the major challenges developers face when launching mobile apps. Currently, developers have to deploy their Android apps to the Google Play Store, which usually takes up to five days for Google to approve the app for download. Similarly, an iOS app takes up to five days for the Apple store to approve an app for download.

Telegram Mini Apps have seen wide adoption in the Web3 ecosystem, where most new crypto startups use Mini Apps to quickly roll out their MVPs and build a community of active users. In this tutorial, we’ll explore the process of developing Telegram Mini Apps as we build and deploy a fullstack ecommerce application with Stripe integration for handling payments.

Prerequisites

Before moving forward with this tutorial, you should have:

  • Knowledge of JavaScript
  • Experience building Node.js APIs
  • Experience building React applications
  • Experience with SQL and the Postgres database
  • Postgres database installed
  • Node.js ≥v20 installed

A brief overview of Mini Apps

A super-app is a single application that provides a one-stop shop for various applications that users need. Mini apps are applications that execute within a super-app. Similarly, Telegram Mini Apps (or TMAs) are web applications that are executed within Telegram, allowing developers to build a variety of applications on the service.

Telegram Mini Apps offer a wide range of functionalities, including gaming, content sharing, productivity tools, ecommerce, and more. Included among the many benefits they offer are:

  • Familiarty with development frameworks: Developers can build Telegram Mini Apps using web development technologies like HTML, CSS, and JavaScript. This familiarity streamlines the development process, allowing developers to utilize existing skills and tools. Additionally, Telegram provides developer tools and APIs for creating these apps and integrating them with the Telegram platform very easily
  • Monetization opportunities: Telegram Mini Apps can be monetized in many ways, including in-app purchases, subscription models, or advertising. Developers can integrate ads into their TMAs, generating revenue from ad impressions or clicks, which make them attractive to businesses
  • Cross-platform compatibility: TMAs work across different operating systems and devices. Because they are web-based, users have a consistent experience, whether on Android, iOS, or desktop
  • Security and privacy: As TMAs run within Telegram, they benefit from Telegram’s end-to-end encryption and other security features
  • No installation hassle: Because TMAs don’t require a separate download, users can access them instantly on Telegram

Building an ecommerce Telegram mini-app with React and Node.js

In this section, we will create an ecommerce application with React and Node.js that will run on Telegram as a Mini App. Our ecommerce application will have the following features:

  • User authentication and authorization (JWT)
  • Product management (CRUD operations for products)
  • Order management (CRUD operations for orders)
  • Payment handling using the Stripe payment gateway

Setting up the database

Because we’re using Postgres database, which is an SQL database, we need to create tables and establish relationships between the tables.

Open psql, create your database, and run the following command to create the required tables for the application:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    price NUMERIC(10, 2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    total NUMERIC(10, 2) NOT NULL,
    status VARCHAR(50) DEFAULT 'Pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INTEGER REFERENCES orders(id),
    product_id INTEGER REFERENCES products(id),
    quantity INTEGER NOT NULL,
    price NUMERIC(10, 2) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

This is the database schema for the ecommerce application. The SQL statements create a set of tables for managing users, products, orders, and order items in a database, with appropriate data types, constraints, and relationships between the tables.

Creating a RESTful API with Express.js

We need a backend application that communicates with the database to store and retrieve data across the entire TMA. Let’s start by creating a Node.js project and installing dependencies with the following command:

npm install bcryptjs express jsonwebtoken pg
Enter fullscreen mode Exit fullscreen mode

Create a .env.postgres file and add the following environment variables. You will need to change the values to use the credentials to connect to your database:

PORT=5100
DB_USER=your_username
DB_HOST=your_db_host
DB_NAME=your_DB_name
DB_PASSWORD=password
DB_PORT=your_db_port
JWT_SECRET=your_jwt_secret
STRIPE_PUBLISHABLE_KEY=you_stripe_publishable_key
STRIPE_SECRET_KEY=you_stripe_secret_key
Enter fullscreen mode Exit fullscreen mode

Create a config/config.js file in the project root directory and add the following:

import dotenv from 'dotenv';
import pkg from 'pg';
const { Pool } = pkg;
dotenv.config();
const config = {
  port: process.env.PORT || 5000,
  db: {
    user: process.env.DB_USER,
    host: process.env.DB_HOST,
    database: process.env.DB_NAME,
    password: process.env.DB_PASSWORD,
    port: process.env.DB_PORT
  },
  jwtSecret: process.env.JWT_SECRET || 'your_jwt_secret'
};
const pool = new Pool(config.db);
export {config, pool};
Enter fullscreen mode Exit fullscreen mode

This config setup allows the application to securely manage our app settings and database connections using environment variables.

Next, create a middlewares/authMiddleware.js file in the project root directory and add the following:

import jwt from 'jsonwebtoken';
import {config} from '../config/config.js';

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.sendStatus(401);
  jwt.verify(token, config.jwtSecret, (err, user) => {
    if (err) return res.sendStatus(403);
    req.user = user;
    next();
  });
};

export { authenticateToken };
Enter fullscreen mode Exit fullscreen mode

The middleware function authenticateToken is used to restrict routes to authenticated users by ensuring that incoming requests contain a valid JWT. If the token is missing, invalid, or expired, the function responds with appropriate HTTP status codes (403). If the token is valid, the decoded user information is attached to the request object, allowing subsequent middleware or route handlers to access the authenticated user's details.

Create a controllers/authController.js file in the project root directory and add the following code to implement user registration:

import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { config, pool } from "../config/config.js";

const register = async (req, res) => {
  const { username, email, password } = req.body;
  const hashedPassword = await bcrypt.hash(password, 10);
  try {
    const result = await pool.query(
      "INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING *",
      [username, email, hashedPassword]
    );
    const { ...user } = result.rows[0];
    res.status(201).json({ ...user, password: null });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
};

export { register, login, profile };
Enter fullscreen mode Exit fullscreen mode

The register function inserts the new user into the users table with the hashed password, and retrieves the newly created user record with the RETURNING * clause, excluding the password.

This is how we would implement user login:

const generateToken = (user) => {
  return jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, {
    expiresIn: "1h",
  });
};

const login = async (req, res) => {
  const { email, password } = req.body;
  try {
    const result = await pool.query("SELECT * FROM users WHERE email = $1", [
      email,
    ]);
    const user = result.rows[0];
    if (user && (await bcrypt.compare(password, user.password))) {
      const token = generateToken(user);
      res.json({ token });
    } else {
      res.status(401).json({ error: "Invalid email or password" });
    }
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

The generateToken function creates a JWT token for a given user, containing their id and username, and signs it with a secret key, setting an expiration time of one hour.

The login function queries the database for a user with the given email, comparing the provided password with the hashed password stored in the database. If the credentials are valid, it generates a JWT token and sends it in the response. If the credentials are invalid, it responds with a 401 Unauthorized status.

The following code is how we would implement a user profile:

const profile = async (req, res) => {
  const { id } = req.params;
  try {
    const result = await pool.query("SELECT * FROM users WHERE id = $1", [req.user.id]);
    if (result.rows.length === 0)
      return res.status(404).json({ message: "User not found" });
    res.status(200).json(result.rows[0]);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

The profile function retrieves the profile information of an authenticated user from the database by the user ID.

Create a productController.js file in the controller directory and add the following code to create a product:

import {config, pool} from '../config/config.js';
export const createProduct = async (req, res) => {
    const { name, description, price } = req.body;
    try {
      const result = await pool.query(
        'INSERT INTO products (name, description, price) VALUES ($1, $2, $3) RETURNING *',
        [name, description, price]
      );
      res.json(result.rows[0]);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
};
Enter fullscreen mode Exit fullscreen mode

The createProduct function inserts the new product into the products table, retrieves the newly created product record with the RETURNING * clause, and returns it as a JSON response.

To get products, run the following code:

export const getProducts = async (req, res) => {
    try {
        const result = await pool.query('SELECT * FROM products');
        res.json(result.rows);
      } catch (error) {
        res.status(500).json({ error: error.message });
      }
};
Enter fullscreen mode Exit fullscreen mode

The getProducts function queries the database for all the available products with the SELECT * clause.

Get a product using its ID with the following code:

export const getProductById = async (req, res) => {
  const { id } = req.params;
  try {
    const result = await pool.query('SELECT * FROM products WHERE id = $1', [id]);
    if (result.rows.length === 0) return res.status(404).json({ message: 'Product not found' });
    res.status(200).json(result.rows[0]);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

The getProductById function queries the database for a product with the given ID. If the product is not found, it responds with a 404 Not found status.

To update a product, run the following code:

export const updateProduct = async (req, res) => {
    const { id } = req.params;
    const { name, description, price } = req.body;
    try {
      const result = await pool.query(
        'UPDATE products SET name = $1, description = $2, price = $3 WHERE id = $4 RETURNING *',
        [name, description, price, id]
      );
      res.json(result.rows[0]);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
};
Enter fullscreen mode Exit fullscreen mode

The updateProduct function allows the modification of product details by their ID in the database.

The following code can be used to delete a product:

export const deleteProduct = async (req, res) => {
    const { id } = req.params;
    try {
      await pool.query('DELETE FROM products WHERE id = $1', [id]);
      res.sendStatus(204);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
};
Enter fullscreen mode Exit fullscreen mode

The deleteProduct function deletes a product from the database using the product ID.

Create a orderController.js file in the controller directory and add the following to create an order:

import {config, pool} from '../config/config.js';

export const createOrder = async (req, res) => {
    const { items } = req.body;
    try {
      const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
      const orderResult = await pool.query(
        'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING *',
        [req.user.id, total]
      );
      const order = orderResult.rows[0];

      const orderItemsQueries = items.map(item => {
        return pool.query(
          'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)',
          [order.id, item.product_id, item.quantity, item.price]
        );
      });

      await Promise.all(orderItemsQueries);
      res.json(order);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
};
Enter fullscreen mode Exit fullscreen mode

The createOrder function creates a new order in the database by extracting the order items from the request body, calculating the total cost of the order, inserting a new order into the orders table with the user ID and total cost, then inserting each item in the order into the order_items table with the corresponding order_id, product_id, quantity, and price. It then sends the newly created order as a JSON response.

Run the following code to get orders:

export const getOrders = async (req, res) => {
    try {
        const result = await pool.query('SELECT * FROM orders WHERE user_id = $1', [req.user.id]);
        res.json(result.rows);
      } catch (error) {
        res.status(500).json({ error: error.message });
      }
};
Enter fullscreen mode Exit fullscreen mode

The getOrders function queries the database for all the available orders with the SELECT * clause.

In order to get an order by ID, run this:

export const getOrderById = async (req, res) => {
    const { id } = req.params;
    try {
      const result = await pool.query('SELECT * FROM orders WHERE id = $1 AND user_id = $2', [id, req.user.id]);
      if (result.rows.length === 0) return res.status(404).json({ message: 'Contact not found' });
      res.status(200).json(result.rows[0]);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
};
Enter fullscreen mode Exit fullscreen mode

The getOrderById function queries the database for an order with the given ID. If the order is not found, it responds with a 404 Not found status.

Create a paymentController.js file in the controller directory and add the following:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-06-20",
});

export const config = (req, res) => {
  res.send({
    publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
  });
};

export const paymentIntent = async (req, res) => {
  const { currency, amount } = req.body;
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      currency: currency ? currency : "usd",
      amount: amount * 100,
      automatic_payment_methods: { enabled: true },
    });
    res.send({
      clientSecret: paymentIntent.client_secret,
    });
  } catch (e) {
    return res.status(400).send({
      error: {
        message: e.message,
      },
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

The above code snippet integrates with Stripe to manage payment processing. The config function provides the Stripe publishable key to the client while the paymentIntent function creates a payment intent with Stripe using the provided currency and amount. It then returns the client secret needed to complete the payment on the client side.

To get your STRIPE_PUBLISHABLE_KEY and STRIPE_SECRET_KEY, you’ll need to sign up for a Stripe developer account, turn on the test mode toggle, and click on Developers > API keys.

Next, create a routes/authRoutes.js file in the project root directory and add the following:

import express from 'express';
import { register, login, profile } from '../controllers/authController.js';
import { authenticateToken } from '../middlewares/authMiddleware.js';

const router = express.Router();
router.post('/register', register);
router.post('/login', login);
router.route('/profile')
    .get(authenticateToken, profile);

export default router;
Enter fullscreen mode Exit fullscreen mode

The above code sets up an Express router to handle authentication-related routes. The /profile route is protected using the authenticateToken middleware, ensuring that only authenticated users can access it.

Then, create a orderRoutes.js file in the routes directory and add the following:

import express from 'express';
import { createOrder, getOrders, getOrderById } from '../controllers/orderController.js';
import { authenticateToken } from '../middlewares/authMiddleware.js';

const router = express.Router();
router.route('/')
  .post(authenticateToken, createOrder)
  .get(authenticateToken, getOrders);
router.route('/:id')
  .get(authenticateToken, getOrderById)

export default router;
Enter fullscreen mode Exit fullscreen mode

This code sets up an Express router to handle order-related routes. All the routes are protected using the authenticateToken middleware, ensuring that only authenticated users can access them.

The following code will set up an Express router to handle payment-related routes. Similarly, all the routes are protected using the authenticateToken middleware.

Create a paymentRoutes.js file in the routes directory and add the following:

import express from 'express';
import { authenticateToken } from '../middlewares/authMiddleware.js';
import { config, paymentIntent } from '../controllers/paymentController.js';

const router = express.Router();
router.route('/create-payment-intent')
  .post(authenticateToken, paymentIntent)
router.route('/config')
  .get(authenticateToken, config);

export default router;
Enter fullscreen mode Exit fullscreen mode

The code below sets up an Express router to handle product-related routes. Create a productRoutes.js file in the routes directory and add the following:

import express from 'express';
import { createProduct, getProducts, getProductById, updateProduct, deleteProduct } from '../controllers/productController.js';
import { authenticateToken } from '../middlewares/authMiddleware.js';

const router = express.Router();
router.route('/')
  .post(authenticateToken, createProduct)
  .get(getProducts);
router.route('/:id')
  .get(getProductById)
  .put(authenticateToken, updateProduct)
  .delete(authenticateToken, deleteProduct);

export default router;
Enter fullscreen mode Exit fullscreen mode

Next, create a server.js file in the project root directory and add the following:

import express from "express";
import bodyParser from "body-parser";
import authRoutes from "./routes/authRoutes.js";
import productRoutes from "./routes/productRoutes.js";
import orderRoutes from "./routes/orderRoutes.js";
import paymentRoutes from "./routes/paymentRoutes.js"
import { config } from "./config/config.js";
import cors from "cors";

const corsOptions = {
  credentials: true,
  origin: ["http://localhost:3000", "https://shopeefai.netlify.app"], // Whitelist the domains you want to allow
};
const app = express();
app.use(cors(corsOptions));
app.use(bodyParser.json());
app.use("/api/auth", authRoutes);
app.use("/api/products", productRoutes);
app.use("/api/orders", orderRoutes);
app.use("/api/payments", paymentRoutes);

app.listen(config.port, () => {
  console.log(`Server is running on port ${config.port}`);
});
Enter fullscreen mode Exit fullscreen mode

This code sets up an Express server with middleware and routes for handling authentication, product management, order processing, and payment features. It also includes CORS configuration to allow specific domains to access the server.

Now you can test your API with Postman to confirm that each endpoint works as expected before integrating with the frontend application.

Building the ecommerce application frontend with React

Create a React project and install the following dependencies:

npm install react-icons react-router-dom @stripe/react-stripe-js @stripe/stripe-js axios
Enter fullscreen mode Exit fullscreen mode

Next, create a .env.postgres file and add the following environment variables. You will need to change the values to use the credentials to connect to your API server:

REACT_APP_API_URL=your_api_url
Enter fullscreen mode Exit fullscreen mode

In the src directory, create a constants/index.js file and add the following:

export const BASE_URL = process.env.REACT_APP_API_URL;
Enter fullscreen mode Exit fullscreen mode

Next, we’ll set up Tailwind for styling. Create tailwind.config.js file in the project root directory and add the following:

module.exports = {
    content: [
      "./src/**/*.{js,jsx,ts,tsx}",
    ],
    theme: {
      extend: {},
    },
    plugins: [],
  }
Enter fullscreen mode Exit fullscreen mode

In the src directory, create a tailwind.css file and add the following:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Then, still in the src directory, create a components/Spinner.js file and add the following:

import { ImSpinner6 } from "react-icons/im";
const Spinner = ({ className }) => {
  return (
    <ImSpinner6
      className={`animate-spin text-[#F95D44] ${className} `}
    />
  );
};
export default Spinner;
Enter fullscreen mode Exit fullscreen mode

To set up the context for global state management, create a context/AuthContext.js file in the src directory and add the following:

import React, { createContext, useState, useEffect } from 'react';
import axios from 'axios';
import { BASE_URL } from '../constants';

const AuthContext = createContext(null);

const AuthProvider = ({ children }) => {
  const [user, setUser] = useState({});
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      axios.get(`${BASE_URL}/auth/profile`)
        .then(response => setUser(response.data))
        .catch(() => setUser(null));
    }
  }, []);

  const login = async (email, password) => {
    const response = await axios.post(`${BASE_URL}/auth/login`, { email, password });
    const token = response.data.token;
    localStorage.setItem('token', token);
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    const user = await axios.get(`${BASE_URL}/auth/profile`);
    setUser(user.data);
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export { AuthContext, AuthProvider };
Enter fullscreen mode Exit fullscreen mode

The AuthContext.js sets up an authentication context and provider to manage user state, login, and logout functionality across the application.

The login function sends a POST request to the login endpoint with the provided email and password, saves the received JWT token to localStorage, sets the authorization header for subsequent Axios requests, fetches the user's profile data, and updates the user state.

The logout function handles user logout by removing the JWT token from localStorage and setting the user state to null.

The register function handles user registration by sending user details (username, email, password) to the /auth/register endpoint of the API.

In the context directory, create a CartContext.js file and add the following:

import React, { createContext, useState } from "react";
const CartContext = createContext();

const CartProvider = ({ children }) => {
  const [cart, setCart] = useState([]);
  const [cartTotal, setCartTotal] = useState(0);

  const addToCart = (product) => {
    const item = cart.find((i) => i.id === product.id);
    if (item) {
      setCart(cart.map((item) => item.id === product.id ? { ...item, quantity: Number(item.quantity) + 1, price: Number(item.price) + Number(product.price) } : item));
    } else {
      setCart([...cart, {...product, quantity: 1}]);
    }
  };

  const removeFromCart = (product) => {
    const item = cart.find((i) => i.id === product.id);
    if (item) {
      setCart( cart.filter((item) => item.id !== product.id ));
    }
  };

  const clearCart = () => {
    setCart([]);
  };

  return (
    <CartContext.Provider
      value={{ cart, addToCart, removeFromCart, clearCart, setCartTotal, cartTotal }}
    >
      {children}
    </CartContext.Provider>
  );
};
export { CartContext, CartProvider };
Enter fullscreen mode Exit fullscreen mode

The CartContext.js sets up a React context and provider to manage the shopping cart functionality across the application.

Next, we’ll implement a register. In the components directory, create a Register.js file and add the following:

import React, { useState, useContext } from "react";
import { AuthContext } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
const Register = () => {
  const { register } = useContext(AuthContext);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [username, setUsername] = useState("");

  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true)
    try {
      await register(username, email, password);
      navigate("/login");
      setIsLoading(false)
    } catch (error) {
      console.error("Failed to register", error);
      setIsLoading(false)
    }
  };
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded shadow-md">
        <h2 className="text-2xl font-bold text-center">Register</h2>
        <form onSubmit={handleSubmit} className="space-y-6">
        <div>
            <label
              htmlFor="username"
              className="block text-sm font-medium text-gray-700"
            >
              Username
            </label>
            <input
              type="text"
              id="username"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <label
              htmlFor="email"
              className="block text-sm font-medium text-gray-700"
            >
              Email
            </label>
            <input
              type="email"
              id="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <label
              htmlFor="password"
              className="block text-sm font-medium text-gray-700"
            >
              Password
            </label>
            <input
              type="password"
              id="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <p><Link className="text-blue-900" to={'/login'}>Login</Link> to an existing account</p>
          <div>
            <button
              type="submit"
              disabled={isLoading}
              className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring"
            >
              {isLoading ? "Processing..." : "Login"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
};
export default Register;
Enter fullscreen mode Exit fullscreen mode

The Register component handles user registration using React Hooks and Context to manage and submit the registration form.

Now, to implement a login functionality, we’ll create an Auth.js file in the components directory and add the following:

import React, { useState, useContext } from "react";
import { AuthContext } from "../context/AuthContext";
import { useNavigate } from "react-router-dom";
const Auth = () => {
  const { login } = useContext(AuthContext);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();

const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true)
    try {
      await login(email, password);
      navigate("/");
      setIsLoading(false)
    } catch (error) {
      console.error("Failed to login", error);
      setIsLoading(false)
    }
  };

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded shadow-md">
        <h2 className="text-2xl font-bold text-center">Login</h2>
        <form onSubmit={handleSubmit} className="space-y-6">
          <div>
            <label
              htmlFor="email"
              className="block text-sm font-medium text-gray-700"
            >
              Email
            </label>
            <input
              type="email"
              id="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <label
              htmlFor="password"
              className="block text-sm font-medium text-gray-700"
            >
              Password
            </label>
            <input
              type="password"
              id="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <button
              type="submit"
              disabled={isLoading}
              className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring"
            >
              {isLoading ? "Processing..." : "Login"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
};

export default Auth;
Enter fullscreen mode Exit fullscreen mode

The Auth component handles user login using React Hooks and Context to manage and submit the login form. It uses AuthContext to access the login function for authentication.

Next, we’ll implement a product list by creating a ProductList.js file in the components directory, and adding the following code:

import React, { useEffect, useState, useContext } from 'react';
import axios from 'axios';
import { CartContext } from '../context/CartContext';
import { BASE_URL } from '../constants';
import Spinner from './Spinner';
const ProductList = () => {
  const [products, setProducts] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const { addToCart } = useContext(CartContext);
  useEffect(() => {
    fetchProducts();
  }, []);
  const fetchProducts = async () => {
    setIsLoading(true)
    try {
      const response = await axios.get(`${BASE_URL}/products`);
      setProducts(response.data);
      setIsLoading(false)
    } catch (error) {
      console.error('Failed to fetch products', error);
      setIsLoading(false)
    }
  };
  return (
    <div className="container mx-auto p-4">
      <h2 className="text-2xl font-bold mb-4">Products</h2>
      <ul className="space-y-4">
        {isLoading ?
            <div className='flex justify-center items-center w-full'>
                <Spinner className={"text-2xl w-10 h-10"}/>
            </div> :
            products.length > 0 ? products.map((product) => (
          <li key={product.id} className="p-4 bg-white rounded shadow-md">
            <div className="flex items-center justify-between">
              <div>
                <h3 className="text-xl font-bold">{product.name}</h3>
                <p className="text-gray-700">${product.price}</p>
              </div>
              <div className="space-x-2">
                <button
                  onClick={() => addToCart(product)}
                  className="px-4 py-2 font-bold text-white bg-green-500 rounded hover:bg-green-700"
                >
                  Add to Cart
                </button>
              </div>
            </div>
          </li>
        )) : <p className='text-center'>No Product Available</p>}
      </ul>
    </div>
  );
};
export default ProductList;
Enter fullscreen mode Exit fullscreen mode

The ProductList component fetches and displays a list of products from the API. Users can view these products and add them to their shopping cart using CartContext.

Next, we’ll implement a product form. In the components directory, create ProductForm.js file and add the following:

import React, { useState, useContext } from 'react';
import axios from 'axios';
import { AuthContext } from '../context/AuthContext';
import { BASE_URL } from '../constants';
const ProductForm = () => {
  const { user } = useContext(AuthContext);
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [price, setPrice] = useState('');
  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!user) return;
    try {
      await axios.post(`${BASE_URL}/products`, { name, description, price });
      setName('');
      setDescription('');
      setPrice('');
    } catch (error) {
      console.error('Failed to create product', error);
    }
  };
  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
        <input
          type="text"
          id="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
        />
      </div>
      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
        <input
          type="text"
          id="description"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
        />
      </div>
      <div>
        <label htmlFor="price" className="block text-sm font-medium text-gray-700">Price</label>
        <input
          type="number"
          id="price"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300"
        />
      </div>
      <div>
        <button type="submit" className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring">Add Product</button>
      </div>
    </form>
  );
};
export default ProductForm;
Enter fullscreen mode Exit fullscreen mode

The ProductForm component allows authenticated users to add new products to a product list by submitting a form.

Now, we’ll implement a cart where users can view their items, remove them, and proceed to the checkout page. The Cart component will help us achieve this functionality. In the components directory, create a Cart.js file and add the following:

import React, { useContext, useEffect } from "react";
import { CartContext } from "../context/CartContext";
import { useNavigate } from "react-router-dom";
const Cart = () => {
  const { cart, removeFromCart, setCartTotal } = useContext(CartContext);
  const navigate = useNavigate();
  const cartTotal = cart.reduce((sum, item) => {
    return sum + parseFloat(item.price) * item.quantity;
  }, 0);
  useEffect(() => {
    setCartTotal(cartTotal);
  }, []);
  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">Cart</h2>
      <ul className="space-y-4">
        {cart.map((product) => (
          <li key={product.id} className="p-4 bg-white rounded shadow-md">
            <div className="flex items-center justify-between">
              <div>
                <h3 className="text-xl font-bold">{product.name}</h3>
                <p className="text-gray-700">${product.price}</p>
                <p className="text-gray-700">Quantity: {product.quantity}</p>
              </div>
              <button
                onClick={() => removeFromCart(product)}
                className="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700"
              >
                Remove
              </button>
            </div>
          </li>
        ))}
      </ul>
      <p className="text-xl font-bold mt-6">Total: ${cartTotal}</p>
      <button
        onClick={() => navigate("/checkout")}
        className="w-full px-4 py-2 mt-4 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
      >
        Checkout
      </button>
    </div>
  );
};
export default Cart;
Enter fullscreen mode Exit fullscreen mode

In the next step, we’ll implement an order list using the OrderList component, which displays a list of orders for the authenticated user. In the components directory, create an OrderList.js file and add the following:

import React, { useEffect, useState, useContext } from 'react';
import axios from 'axios';
import { AuthContext } from '../context/AuthContext';
import { BASE_URL } from '../constants';
import Spinner from './Spinner';
const OrderList = () => {
  const [orders, setOrders] = useState([]);
  const { user } = useContext(AuthContext);
  const [isLoading, setIsLoading] = useState(false);
  useEffect(() => {
    fetchOrders();
  }, []);
  const fetchOrders = async () => {
    setIsLoading(true)
    try {
      const response = await axios.get(`${BASE_URL}/orders`);
      setOrders(response.data);
      setIsLoading(false)
    } catch (error) {
      console.error('Failed to fetch orders', error);
      setIsLoading(false)
    }
  };
  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">Orders</h2>
      <ul className="space-y-4">
        {isLoading ?
            <div className='flex justify-center items-center w-full'>
                <Spinner className={"text-2xl w-10 h-10"}/>
            </div> :
            orders.length > 0 ? orders.map((order) => (
          <li key={order.id} className="p-4 bg-white rounded shadow-md">
            <div className="flex items-center justify-between">
              <div>
                <p className="text-gray-700">Order ID: {order.id}</p>
                <p className="text-gray-700">Total: ${order.total}</p>
                <p className="text-gray-700">Status: <span className="bg-yellow-600 text-white py-1 px-3 rounded-md">{order.status}</span></p>
              </div>
            </div>
          </li>
        )) : <p className='text-center'>No Order Available</p>}
      </ul>
    </div>
  );
};
export default OrderList;
Enter fullscreen mode Exit fullscreen mode

Next, we’ll implement a Stripe payment process in our user’s cart using the Payment component. This interacts with the Stripe API to create a payment intent and configure Stripe elements.

In the components directory, create a Payment.js file and add the following:

import { useContext, useEffect, useState } from "react";
import axios from 'axios';
import { Elements } from "@stripe/react-stripe-js";
import CheckoutForm from "./CheckoutForm";
import { loadStripe } from "@stripe/stripe-js";
import { CartContext } from "../context/CartContext";
import { BASE_URL } from "../constants";
function Payment() {
    const { cartTotal } = useContext(CartContext);
  const [stripePromise, setStripePromise] = useState(null);
  const [clientSecret, setClientSecret] = useState("");
  const fetchConfig = async () => {
    try {
        const response = await axios.get(`${BASE_URL}/payments/config`)
        setStripePromise(loadStripe(response.data.publishableKey));
    } catch (error) {
        console.log(error)
    }
  }
  useEffect( () => {
    fetchConfig()
  }, []);
  const paymentIntent = async () => {
    try {
        const response = await axios.post(`${BASE_URL}/payments/create-payment-intent`, {currency: 'usd', amount: cartTotal})
        setClientSecret(response.data.clientSecret);
    } catch (error) {
        console.log(error)
    }
  }
  useEffect( () => {
    paymentIntent()
  }, []);
  return (
    <>
      <h1>Your Total is: ${cartTotal}</h1>
      {clientSecret && stripePromise && (
        <Elements stripe={stripePromise} options={{ clientSecret }}>
          <CheckoutForm />
        </Elements>
      )}
    </>
  );
}
export default Payment;
Enter fullscreen mode Exit fullscreen mode

One of our last steps will be to implement a completion page using the Completion component. This handles the post-payment process by creating an order based on the items in the user's cart. In the components directory, create a Completion.js file and add the following:

import axios from 'axios';
import React, { useContext, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { CartContext } from '../context/CartContext';
import { BASE_URL } from '../constants';
const Completion = () => {
    const { cart } = useContext(CartContext);
    const createOrder = async () => {
        try {
          await axios.post(`${BASE_URL}/orders`, {items: cart});
        } catch (error) {
          console.error('Failed to create product', error);
        }
      };
    useEffect(() => {
      createOrder()
    }, [])

  return (
    <div className='text-center'>
    <p className='text-xl mb-6'>Payment Successful</p>
    <Link to={"/orders"} className='bg-green-700 p-2 rounded text-base text-white'>Track Order</Link>
    </div>
  )
}
export default Completion
Enter fullscreen mode Exit fullscreen mode

We're almost at the end! Now, we’ll add a product checkout form using the CheckoutForm component. This component handles the Stripe payment process, configures payment using Stripe’s API, and displays the appropriate message based on the payment status.

In the components directory, create a CheckoutForm.js file and add the following code. We will also use the PaymentElement component to provide a form to collect payment details:

import { useState } from "react";
import { useStripe, useElements, PaymentElement } from "@stripe/react-stripe-js";
import { BASE_URL } from "../constants";
import { useNavigate } from "react-router-dom";
export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const navigate = useNavigate()
  const [message, setMessage] = useState(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!stripe || !elements) {
      return;
    }
    setIsProcessing(true);
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `/completion`,
      },
      redirect: 'if_required'
    });
    if (error.type === "card_error" || error.type === "validation_error") {
      setMessage(error.message);
    } else {
      setMessage("An unexpected error occurred.");
    }
    navigate("/completion")
    setIsProcessing(false);
  };

  return (
    <form id="payment-form" onSubmit={handleSubmit}>
      <PaymentElement id="payment-element" />
      <button disabled={isProcessing || !stripe || !elements} id="submit">
        <span id="button-text">
          {isProcessing ? "Processing ... " : "Pay now"}
        </span>
      </button>
      {message && <div id="payment-message">{message}</div>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we’ll finish things off by implementing the App component, which sets up the main structure and routing of the application using AuthContext to manage and check the user's authentication status and redirect to the login page if necessary.

Update the App.js file with the following:

import React, { useContext } from "react";
import {
  BrowserRouter as Router,
  Route,
  Routes,
  Navigate,
  Link,
} from "react-router-dom";
import { AuthContext } from "./context/AuthContext";
import Auth from "./components/Auth";
import Cart from "./components/Cart";
import OrderList from "./components/OrderList";
import ProductForm from "./components/ProductForm";
import Payment from "./components/Payment";
import Completion from "./components/Completion";
import ProductList from "./components/ProductList";
import Register from "./components/Register";
const App = () => {
  const { user, logout } = useContext(AuthContext);
  return (
    <Router>
      <nav className="flex justify-between flex-wrap shadow-lg px-4 mb-6 items-center h-20">
        <div className="flex space-x-2 items-center">
        <Link to={"/"} className="font-bold text-xl">Shopee</Link>
          <Link to={"/cart"}>Cart</Link>
          <Link to={"/orders"}>Orders</Link>
        </div>
        {user ? (
          <div className="flex items-center space-x-2">
            <h1 className="text-base font-bold">Welcome, {user.username}</h1>
            <button
              onClick={logout}
              className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
            >
              Logout
            </button>
          </div>
        ) : <Link
        to={"login"}
        className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
      >
        Login
      </Link>}
      </nav>
      <Routes>
        <Route path="/login" element={<Auth />} />
        <Route path="/register" element={<Register />} />
        <Route
          path="/orders"
          element={user ? <OrderList /> : <Navigate to="/login" />}
        />
        <Route path="/" element={<ProductList />} />
        <Route path="/cart" element={<Cart />} />
        <Route path="/add-product" element={user ? <ProductForm /> : <Navigate to="/login" />} />
        <Route path="/checkout" element={user ? <Payment /> : <Navigate to="/login" />} />
        <Route path="/completion" element={user ? <Completion /> : <Navigate to="/login" />}/>
      </Routes>
    </Router>
  );
};
export default App;
Enter fullscreen mode Exit fullscreen mode

Deploying our React app

In this section, we’ll deploy our API server and React application to a remote server.

Use the following steps to deploy your React application to Netlify:

  1. Create a GitHub repository
  2. Push your React codebase to the remote repository
  3. Sign in to your Netlify account
  4. Choose the option to deploy the project with GitHub
  5. Modify the build settings to fit your project structure
  6. Add your environment variables
  7. Click on the Deploy button

Use the following steps to deploy your API server to Render:

  1. Create a GitHub repository for the API codebase
  2. Push your API codebase to the remote repository
  3. Head over to Render and click the Get Started for Free button
  4. Sign up if you do not have an account
  5. Navigate to web service, search, and select your repository
  6. Modify the deployment settings to fit your project structure
  7. Select the free instance type
  8. Add your environment variables
  9. Click on the Deploy web service button

After successful deployment, you’ll see the URL to access the API remotely.

Because we’re using the Postgres database for our ecommerce application, we must create a Postgres database on Render:

  1. Click on the new button and navigate to PostgreSQL
  2. Enter the required information to create a database
  3. Select the free instance type
  4. Click on the Create Database button

After the PostgreSQL database is successfully created, head over to the deployed API server and update your environment variables with the newly created database information so the API server can access the database.

Configuring the Telegram bot

In this section, we will set up and configure a Telegram bot to transition our fullstack ecommerce application to a Mini App running on Telegram.
Open your Telegram application and search for BotFather. Join the BotFather bot, then type /start to access the Telegram Bot API.

Type /newbot to create a new bot, and then you will be prompted with Please choose a name for your bot,” followed by choose a username for your bot. After responding to the prompt with the required information, your bot will be created.

Next, type /newapp to create a new web app then type @your_bot_name. This will create a new web app for your bot and prompt you with the following:

1\. Please enter a title for the web app.
2\. Please enter a short description of the web app.
3\. Please upload a photo, 640x360 pixels.
4\. Now upload a demo GIF or send /empty to skip this step.
5\. Now please send me the Web App URL that will be opened when users follow a web app direct link.
6\. Now please choose a short name for your web app

After responding to the prompt with the required information, your web app will be transformed into a Mini App that runs on Telegram. Your Mini App URL will be in the format t.me/bot_name/web_app_name.

If you followed along correctly, here is what your Telegram Mini App should look like: Telegram Mini App Final You can interact with the final version of the Mini App demo at t.me/shopzify_bot/shopzify.

Here are the GitHub repositories for the full-stack ecommerce application:

Conclusion

In this tutorial, we looked at the process of creating a full-stack, ecommerce Telegram Mini App using React, Node.js, Express, and Postgres. This demo should give you an idea of how to use familiar technologies to create mini apps in a super-app ecosystem.

There are so many ways this process can be improved. What types of mini app would you build using Telegram's platform? Or how have you already implemented Telegram Mini Apps in your projects? Let us know in the comments below!


Get set up with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)