Build a Vue 3 Mini App Storefront with Pinia and Telegram Native Invoices

Building an e-commerce storefront inside a Telegram Mini App provides a seamless user experience, but it introduces state management and payment flow challenges distinct from regular web applications. When users navigate through a Vue 3 catalog inside Telegram's embedded WebApp webview, they expect native UI integration. Using custom HTML checkout buttons instead of Telegram's native MainButton breaks this native feeling, while relying on unsynchronized in-memory state leads to frustrating cart resets whenever the webview reloads or closes.

A naive approach often places item prices and cart totals directly inside client-side components and submits these amounts to a payment provider. If your frontend submits the final price calculated in JavaScript to generate a payment invoice, malicious users can open Telegram WebApp developer tools, edit the JavaScript state, and purchase items for cents.

To build a production-ready catalog in Vue 3, you need a robust persistent cart using Pinia, seamless two-way binding with Telegram's native MainButton, and a backend-driven checkout pipeline that issues Telegram Bot API invoice links after verifying item prices on the server.

State Management: Building a Persistent Pinia Store

The first point of failure in a Mini App catalog is cart state volatile behavior. When users minimize the Mini App, answer a chat message, or receive an incoming call, the Telegram client may discard or refresh the webview instance. If your cart relies solely on standard Vue ref state, the cart wipes clean upon restore.

To prevent cart state loss, we implement a Pinia store that automatically serializes cart changes to localStorage. We must also structure the store to track item quantities and recalculate item totals reactively.

Create your catalog cart store inside src/stores/cart.js or src/stores/cart.ts:

import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';

export interface CartItem {
id: string;
title: string;
price: number; // in minor units, e.g., cents or smallest currency unit
quantity: number;
}

export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);

// Hydrate cart from localStorage on initialization
const savedCart = localStorage.getItem('telegram_cart_items');
if (savedCart) {
try {
items.value = JSON.parse(savedCart);
} catch (e) {
console.error('Failed to parse cached cart items:', e);
localStorage.removeItem('telegram_cart_items');
}
}

// Persist changes to localStorage automatically
watch(
items,
(newItems) => {
localStorage.setItem('telegram_cart_items', JSON.stringify(newItems));
},
{ deep: true }
);

const totalAmount = computed(() => {
return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

const totalCount = computed(() => {
return items.value.reduce((sum, item) => sum + item.quantity, 0);
});

function addItem(product: { id: string; title: string; price: number }) {
const existing = items.value.find((i) => i.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
items.value.push({ ...product, quantity: 1 });
}
}

function removeItem(productId: string) {
const index = items.value.findIndex((i) => i.id === productId);
if (index !== -1) {
if (items.value[index].quantity > 1) {
items.value[index].quantity -= 1;
} else {
items.value.splice(index, 1);
}
}
}

function clearCart() {
items.value = [];
localStorage.removeItem('telegram_cart_items');
}

return {
items,
totalAmount,
totalCount,
addItem,
removeItem,
clearCart,
};
});

Using watch with { deep: true } ensures that updating an item's quantity syncs immediately to local storage. By storing prices in minor currency units (such as 1500 for $15.00 or 1500 stars/cents), you avoid floating-point rounding errors when calculating cart subtotals.

Syncing Vue Reactive State with Telegram MainButton

Telegram provides a native MainButton fixed to the bottom of the WebApp viewport. Rendering a custom fixed position HTML button inside your Vue template creates visual inconsistency, conflicts with soft keyboard safe areas, and overlaps Telegram's native footer interface.

However, synchronizing Vue's reactive state with window.Telegram.WebApp.MainButton presents another common trap: event handler accumulation. If you register click listeners on MainButton inside Vue lifecycle hooks without removing them on unmount or reactive changes, clicking the button once will trigger multiple duplicate order submissions.

To encapsulate this cleanly, build a custom Vue composable that watches Pinia store changes and safely handles MainButton lifecycle events.

Create src/composables/useTelegramMainButton.ts:

import { watch, onUnmounted } from 'vue';
import { useCartStore } from '../stores/cart';

export function useTelegramMainButton(onCheckoutTriggered: () => void) {
const cartStore = useCartStore();
const tg = window.Telegram?.WebApp;

if (!tg) {
return;
}

const handleMainButtonClick = () => {
onCheckoutTriggered();
};

// Ensure button is initialized
tg.MainButton.onClick(handleMainButtonClick);

// Watch cart changes and update Telegram native button UI
watch(
[() => cartStore.totalAmount, () => cartStore.totalCount],
([newAmount, newCount]) => {
if (newCount > 0) {
const formattedPrice = (newAmount / 100).toFixed(2);
tg.MainButton.setText(`VIEW CART ($${formattedPrice})`);
tg.MainButton.enable();
tg.MainButton.show();
} else {
tg.MainButton.hide();
}
},
{ immediate: true }
);

// Clean up listener to prevent duplicate clicks on re-render
onUnmounted(() => {
tg.MainButton.offClick(handleMainButtonClick);
tg.MainButton.hide();
});

function showLoading(text = 'Processing...') {
tg.MainButton.setText(text);
tg.MainButton.showProgress();
tg.MainButton.disable();
}

function hideLoading() {
tg.MainButton.hideProgress();
tg.MainButton.enable();
}

return {
showLoading,
hideLoading,
};
}

This composable ensures that when the catalog cart is empty, the native button vanishes automatically. When items are added, the button smoothly animates into place with the calculated total amount, mimicking the feel of native iOS and Android apps.

Secure Checkout: Backend Invoice Generation

Never trust the frontend to provide final prices or product amounts. A complete checkout pipeline must send only item identifiers and quantities to a secure backend route. The backend must query the database, compute the exact total, generate a Telegram payment link using the official Bot API method createInvoiceLink, and return that URL to the Vue client.

Here is a Node.js Express endpoint demonstrating secure invoice creation:

import express from 'express';
import fetch from 'node-fetch';

const app = express();
app.use(express.json());

const BOT_TOKEN = process.env.BOT_TOKEN;
const PAYMENT_PROVIDER_TOKEN = process.env.PAYMENT_PROVIDER_TOKEN; // Empty for Telegram Stars

// Mock server database
const PRODUCT_DATABASE = {
'prod_1': { title: 'Premium Coffee Beans', price: 1500 }, // $15.00
'prod_2': { title: 'Ceramic Mug', price: 1200 }, // $12.00
};

app.post('/api/checkout', async (req, res) => {
const { initData, cart } = req.body;

// IMPORTANT: Verify initData HMAC signature in production here!
if (!initData) {
return res.status(401).json({ error: 'Unauthorized request' });
}

// Calculate prices strictly from backend database
const invoicePrices = [];
for (const item of cart) {
const dbProduct = PRODUCT_DATABASE[item.id];
if (!dbProduct) {
return res.status(400).json({ error: `Invalid product ID: ${item.id}` });
}
invoicePrices.push({
label: `${dbProduct.title} x${item.quantity}`,
amount: dbProduct.price * item.quantity,
});
}

if (invoicePrices.length === 0) {
return res.status(400).json({ error: 'Cart is empty' });
}

try {
const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/createInvoiceLink`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Store Checkout',
description: 'Order from Mini App Storefront',
payload: JSON.stringify({ order_id: Date.now() }), // Unique tracking payload
provider_token: PAYMENT_PROVIDER_TOKEN,
currency: 'USD',
prices: invoicePrices,
}),
});

const data = await response.json();

if (!data.ok) {
console.error('Telegram Bot API Error:', data);
return res.status(500).json({ error: 'Failed to generate payment invoice' });
}

// Return generated invoice URL to frontend
res.json({ invoiceUrl: data.result });
} catch (error) {
console.error('Server error during checkout:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

This pattern ensures product pricing rules remain completely hidden from the client layer. If a user modifies item quantities or prices in local memory, the server checks product IDs against PRODUCT_DATABASE and ignores client-side pricing overrides entirely.

Invoking Native Payments in Vue Component

Now we connect the Vue storefront view to our backend and open Telegram's payment webview using window.Telegram.WebApp.openInvoice.

Create src/views/CatalogView.vue:

<template>
<div class="catalog">
<h1>Products</h1>
<div class="product-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<h3>{{ product.title }}</h3>
<p>${{ (product.price / 100).toFixed(2) }}</p>
<div class="actions">
<button @click="cartStore.removeItem(product.id)">-</button>
<span>{{ getItemQuantity(product.id) }}</span>
<button @click="cartStore.addItem(product)">+</button>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useCartStore } from '../stores/cart';
import { useTelegramMainButton } from '../composables/useTelegramMainButton';

const cartStore = useCartStore();

const products = ref([
{ id: 'prod_1', title: 'Premium Coffee Beans', price: 1500 },
{ id: 'prod_2', title: 'Ceramic Mug', price: 1200 },
]);

function getItemQuantity(id: string): number {
const item = cartStore.items.find((i) => i.id === id);
return item ? item.quantity : 0;
}

const { showLoading, hideLoading } = useTelegramMainButton(handleCheckout);

async function handleCheckout() {
const tg = window.Telegram?.WebApp;
if (!tg) return;

showLoading('Generating Invoice...');

try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
initData: tg.initData,
cart: cartStore.items.map((item) => ({
id: item.id,
quantity: item.quantity,
})),
}),
});

const data = await response.json();

if (!response.ok || !data.invoiceUrl) {
throw new Error(data.error || 'Invoice generation failed');
}

hideLoading();

// Open native Telegram payment UI
tg.openInvoice(data.invoiceUrl, (status: string) => {
if (status === 'paid') {
tg.HapticFeedback.notificationOccurred('success');
cartStore.clearCart();
tg.showAlert('Thank you for your purchase!');
} else if (status === 'failed') {
tg.HapticFeedback.notificationOccurred('error');
tg.showAlert('Payment was not completed. Please try again.');
} else if (status === 'cancelled') {
// Payment modal closed by user, retain cart
console.log('Payment modal dismissed by user');
}
});
} catch (err: any) {
hideLoading();
tg.showAlert(err.message || 'An error occurred during checkout');
}
}
</script>

When tg.openInvoice opens, Telegram suspends the webview focus and overlays a native modal screen for inputting credit card details or paying via Telegram Stars. The callback provides three status codes: paid, cancelled, or failed.

Only clear the Pinia cart inside the paid branch. If the status is cancelled, leaving the cart intact allows users to adjust their item quantities or attempt checkout again without losing their selected items.

Production Checklist & Edge Cases

When shipping your Vue Mini App catalog to real users, keep these operational details in mind:

1. Calling WebApp.ready() Early: Always invoke window.Telegram.WebApp.ready() inside your root Vue component (App.vue) immediately when mounted. If you delay calling ready(), Telegram shows a loading spinner over your Mini App and may block interaction events on the MainButton.

2. Parsing Telegram WebApp Theme Variables: Users may switch between Dark Mode and Light Mode while the app is active. Ensure your Vue application binds to CSS custom properties provided by Telegram (var(--tg-theme-bg-color), var(--tg-theme-text-color)) rather than hardcoding static hex colors.

3. Handling Replay Attacks and Stale Requests: Even though createInvoiceLink creates isolated links, always record the order_id in your backend database and mark it as fulfilled using a Webhook listener for pre_checkout_query and successful_payment. Relying strictly on the frontend openInvoice callback to fullfill orders is insecure because client network connections can drop before the callback executes.

4. Clearing LocalStorage on Schema Migrations: If you update your product store structure in a future app deployment (e.g., adding variant attributes or changing field names), existing users with old JSON formats stored in localStorage might crash your Vue app. Always wrap JSON.parse() in a try/catch block and clear invalid caches gracefully.

If you need a dedicated team to architect, audit, or scale complex web applications and embedded Telegram solutions, consider reaching out to BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.