How I built a fully custom e-commerce from scratch using Firebase Realtime Database and Netlify — no off-the-shelf platforms: per-product pricing variants, an admin panel protected by a PBKDF2 hash with no password in the source code, duplicate-proof atomic order numbering, and automatic vendor identification via a cross-app token.
The context
A POS terminal reseller needed a showcase site with an integrated e-commerce. The requirements were clear: no payment gateway to integrate (checkout with ready-made payment links or bank transfer details), a catalog of around seven products with selectable pricing variants, and the ability for their sales team to place orders directly from the site — with the salesperson’s name automatically saved on each order, read from their identity already authenticated in the internal management panel.
The decision to skip off-the-shelf e-commerce platforms was deliberate: something fully custom was needed, integrable with the Firebase ecosystem already in use for the management panel, and controllable in every detail. Resulting stack: vanilla HTML/CSS/JS, Firebase Realtime Database for catalog and orders, Firebase Storage for images and PDFs, Netlify for hosting and serverless functions.
Architecture: two separate Firebase projects
The first key decision was keeping the e-commerce database completely separate from the internal management panel’s database. With around forty orders per day (about fifteen thousand a year), merging them into the same Firebase project would have created risk of contamination between commercial data and administrative data (salaries, budgets, employee records).
The e-commerce Realtime Database structure:
{
"products": {
"{id}": {
"name": "Product A",
"price": 0,
"image": "https://...",
"activeTariffs": ["tariff_id_1", "tariff_id_2"]
}
},
"tariffs": {
"{id}": {
"name": "Plan A", "code": "PLAN-CODE",
"fee": "€0/month", "commission": "1.00%",
"pdfUrl": "https://firebasestorage..."
}
},
"orders": {
"{pushKey}": {
"number": 1, "vendor": "Alice",
"customer": { "name": "...", "email": "..." },
"items": [{ "product": "Product A", "tariff": "Plan A" }]
}
},
"counters": {
"lastOrderNumber": 42
}
}
Pricing variants: global archive + per-product assignment
The most interesting data modeling challenge was handling pricing plans. Each POS terminal can be paired with multiple pricing tiers, but the same plans repeat across different products. Embedding tariffs inside each product would mean duplicating data and updating multiple nodes every time a plan changes.
The solution was a global archive at /tariffs with all plans defined once, and per product an activeTariffs array holding the IDs of applicable plans. The admin can check or uncheck plans per product with a checkbox, without touching the plan data itself. Products without plans (e.g. a printer) use the noTariffs: true flag instead.
On the frontend, when the user selects a plan from the product sheet, a detail box updates in real time showing all information: commissions, monthly fee, linked account and card, economic conditions, included services, and — highlighted in monospace — the provider plan code to communicate to the customer.
Order numbering: Firebase atomic transaction
Progressive order numbering sounds trivial, but with multiple vendors placing orders concurrently it becomes a classic race condition problem. If two vendors press “Confirm” at the same instant, both read lastOrderNumber = 41 and write 42 — one order vanishes.
Firebase Realtime Database solves this with atomic transactions: the runTransaction() function attempts the update, and if the value changed on the server in the meantime, it automatically retries until it succeeds with the correct value.
// Atomic counter increment — zero duplicates even with concurrent vendors
async function submitOrder(orderData) {
const counterRef = ref(db, 'counters/lastOrderNumber');
const { snapshot } = await runTransaction(counterRef, currentValue => {
// currentValue is null on the first order
return (currentValue || 0) + 1;
});
const orderNumber = snapshot.val(); // guaranteed unique
const ordersRef = ref(db, 'orders');
await push(ordersRef, {
number: orderNumber,
vendor: orderData.vendorName || 'Anonymous',
customer: orderData.customer,
items: orderData.items,
method: orderData.paymentMethod,
ts: Date.now()
});
}
Note: The
/countersnode in Firebase rules has".read": false— it’s not readable by the client. Only the transaction writes to that node; the number comes back in the transaction response itself.
Admin panel: PBKDF2 with no password in the source
The admin panel lets you manage products (name, price, images, assigned plans) and the global pricing archive (provider codes, fees, conditions, T&C PDFs). It must be accessible only to those who know the password, but without a dedicated backend.
Storing the password in plain text in the source code is obviously out of the question — anyone reading the HTML file sees it. But even hashing isn’t enough with plain MD5 or SHA-256: they’re fast to brute-force. The solution is PBKDF2 (Password-Based Key Derivation Function 2), which applies the hash function thousands of times making it computationally expensive to attack.
The browser’s Web Crypto API exposes PBKDF2 natively. At first setup, the hash is generated outside the site (or with a small Node script) and only the hash and salt are put in the code — never the original password.
// In the source: only salt and hash. The password is never there.
const STORED_SALT = "a7f3..."; // randomly generated at setup
const STORED_HASH = "e9b2..."; // PBKDF2 of the real password
async function verifyPassword(input) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(input), 'PBKDF2', false, ['deriveBits']
);
const derived = await crypto.subtle.deriveBits({
name: 'PBKDF2',
salt: hexToBuffer(STORED_SALT),
iterations: 310000, // expensive to brute-force
hash: 'SHA-256'
}, keyMaterial, 256);
return bufferToHex(derived) === STORED_HASH;
}
After a successful verification, the admin performs an anonymous Firebase sign-in in the background: this allows the database Security Rules to recognize the user as authenticated (auth != null) and authorize writes on products and plans. The catalog remains publicly readable, orders are protected.
The zero-value trap in JavaScript
During development I encountered a subtle bug worth documenting: a terminal’s price was zero (included in the monthly fee), but the site kept showing “price to be defined” even after saving it correctly.
The problem was JavaScript’s falsy evaluation. In two places in the code I was using patterns like:
// ❌ WRONG: 0 is falsy, treated as absent
const price = p.price || null; // 0 → null → Firebase deletes the field
const display = p.price ? ... : 'price to be defined'; // 0 → else branch
// In the admin HTML template:
`value="${p.price || ''}"` // 0 || '' → '', field appears empty on reload
// ✅ CORRECT: != null is true for any number, including zero
const price = p.price != null ? p.price : null;
const display = p.price != null ? ... : 'price to be defined';
`value="${p.price != null ? p.price : ''}"`
The bug manifested in three different places with different effects: during save (the field was deleted by Firebase because it received null), in the admin template (the field appeared empty on reload and the next save wrote null again), and in the frontend display (showing the fallback instead of the price). The fix is always the same: use != null instead of || or a ternary without a guard.
Content Security Policy and Firebase: dynamic subdomains
Adding a Content Security Policy is good security practice, but Firebase Realtime Database uses long-polling as a fallback channel alongside WebSockets, and the subdomains it generates are dynamic and unpredictable (e.g. s-gke-euw1-nssi1-1.europe-west1.firebasedatabase.app). A CSP in an HTML <meta> tag doesn’t support subdomain wildcards, so Firebase was silently blocked.
The solution is moving the CSP from HTML to Netlify’s HTTP headers in netlify.toml, where wildcards work correctly:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = """
default-src 'self';
script-src 'self' 'unsafe-inline'
https://www.gstatic.com
https://cdnjs.cloudflare.com;
connect-src 'self'
https://*.firebasedatabase.app
wss://*.firebasedatabase.app
https://*.googleapis.com
https://firebasestorage.googleapis.com;
img-src 'self' data: blob:
https://firebasestorage.googleapis.com;
"""
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
⚠️
<meta http-equiv="Content-Security-Policy">tags don’t support subdomain wildcards inscript-src. For Firebase — and any service using dynamic subdomains — always configure the CSP in HTTP server headers. On Netlify, that meansnetlify.toml.
Security: Firebase rules and architecture
Real protection isn’t about hiding buttons in JavaScript, but in Firebase Security Rules. The final structure:
{
"rules": {
"products": {
".read": true,
".write": "auth != null"
},
"tariffs": {
".read": true,
".write": "auth != null"
},
"orders": {
".read": "auth != null",
".write": "auth != null"
},
"counters": {
".read": false,
".write": "auth != null"
}
}
}
The next step is replacing the generic auth != null on the orders section with a Firebase custom claim (role: 'vendor') assigned by the Netlify Function that validates the management panel token. This way only vendors with a valid panel-issued token will have access to orders, not just any authenticated user.
The result
A fully custom e-commerce, without off-the-shelf platforms, built on a familiar stack: Firebase for data, Netlify for deployment, vanilla JS for everything else. The vendor opens the site from the management panel with a token in the URL, gets recognized automatically, selects the terminal and plan, fills in customer details, and the order lands on Firebase with a guaranteed-unique sequential number and their name attached.
The most instructive part of the project wasn’t the technical complexity, but how many architectural decisions depend on seemingly simple requirements: separating databases, keeping plans global, using transactions instead of simple writes, moving the CSP from HTML headers to HTTP headers. Each choice prevented a problem that would have surfaced in production.
Originally published on roversia.it
Top comments (2)
Honestly, for this project concurrency wasn’t a primary concern, and that’s by design. This site is an internal order management tool for a small team — not a high-throughput consumer platform. Orders come in through a checkout flow where each submission is a single Firebase push(), which generates a guaranteed-unique key server-side, so write collisions on order creation simply don’t occur structurally.
For status updates (operators editing orders), the risk of two people editing the same order simultaneously is negligible at this scale, so we didn’t implement optimistic locking or runTransaction(). The bigger architectural constraint we worked around was the AWS Lambda 4KB env var limit, which drove more decisions than concurrency did.
If the system needed to scale to concurrent high-volume writes — say, a flash sale — I’d look at Firebase transactions or moving order creation to a queue-backed Netlify Function. But over-engineering for a use case that doesn’t exist yet would have slowed down delivery without real benefit.
How did you handle concurrency issues with atomic orders on Firebase Realtime Database, I'm curious about your approach. Would love to hear more about your experience with this.