Adding a New Feature
This is an end-to-end walkthrough for building a new feature — from database to frontend. We’ll use a hypothetical “Promotions” feature as the example.
Step 1: Define the data model
Section titled “Step 1: Define the data model”Write out what your feature needs to store. Keep it simple:
-- backend/migrations/add_promotions.sqlCREATE TABLE IF NOT EXISTS promotions ( id INT AUTO_INCREMENT PRIMARY KEY, title TEXT NOT NULL, discountPercent INT DEFAULT 0, validUntil TEXT, isActive INT DEFAULT 1, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP);Important: Write two SQL files if the syntax differs between MySQL and SQLite. The project currently does not have a migration tool, so you’ll need to apply them manually (see below). If adding columns to an existing table, use the
ALTER TABLE ... ADD COLUMNguard pattern already in the codebase, or better yet, add a proper migration file.
Applying the migration:
# Local MySQLmysql -u root -p shanvi_travels < backend/migrations/add_promotions.sql
# Cloudflare D1 (staging)npx wrangler d1 execute shanvi-db --local --file=backend/migrations/add_promotions.sql
# Cloudflare D1 (production)npx wrangler d1 execute shanvi-db --file=backend/migrations/add_promotions.sqlStep 2: Create the backend route
Section titled “Step 2: Create the backend route”Create backend/routes/promotions.js:
const express = require('express');const router = express.Router();const { db } = require('../config/db');const { authenticateToken, requireAdmin } = require('../middleware/auth');
// Public: list active promotionsrouter.get('/', (req, res) => { db.all( 'SELECT id, title, discountPercent, validUntil FROM promotions WHERE isActive = 1', [], (err, rows) => { if (err) return res.status(500).json({ error: 'Failed to fetch promotions' }); res.json(rows || []); } );});
// Admin: create promotionrouter.post('/', authenticateToken, requireAdmin, (req, res) => { const { title, discountPercent, validUntil } = req.body; if (!title) return res.status(400).json({ error: 'Title is required' });
db.run( 'INSERT INTO promotions (title, discountPercent, validUntil) VALUES (?, ?, ?)', [title, parseInt(discountPercent) || 0, validUntil || null], function(err) { if (err) return res.status(500).json({ error: 'Failed to create promotion' }); res.status(201).json({ id: this.lastID, message: 'Promotion created' }); } );});
module.exports = router;Rules to follow:
- Always use parameterized queries (
?) — never string interpolation in SQL - Always return a generic error message (not raw
err.message) for non-debug scenarios - Use
authenticateToken+ role guards (requireAdmin,requireAgent) for any protected endpoint - Use
db.run/get/allfromconfig/db.js— never importmysql2or use D1 directly
Step 3: Register the route in index.js
Section titled “Step 3: Register the route in index.js”Open backend/index.js and add two lines:
// At the top with other requires:const promotionsRoutes = require('./routes/promotions');
// With the other app.use calls:app.use('/api/promotions', promotionsRoutes);Step 4: Test the backend
Section titled “Step 4: Test the backend”# Start backendcd backend && npm run dev
# Test public endpointcurl http://localhost:5001/api/promotions
# Test protected endpointcurl -X POST http://localhost:5001/api/promotions \ -H "Authorization: Bearer <your-admin-token>" \ -H "Content-Type: application/json" \ -d '{"title":"Summer Deal","discountPercent":10}'Step 5: Add TypeScript types on the frontend
Section titled “Step 5: Add TypeScript types on the frontend”Add to frontend/src/types/dashboard.ts (or create a new types/promotion.ts):
export interface Promotion { id: number; title: string; discountPercent: number; validUntil: string | null; isActive: number;}Step 6: Build the frontend component
Section titled “Step 6: Build the frontend component”Create the component in the right folder based on who uses it:
- Admin-only feature →
src/components/Dashboard/(pick the right subfolder) - Public-facing →
src/components/orsrc/pages/
Example — src/components/Dashboard/Promotions/PromotionsList.tsx:
import { useState, useEffect } from 'react';import API_BASE_URL from '../../../config/api';
export function PromotionsList({ token }: { token: string }) { const [promotions, setPromotions] = useState([]);
useEffect(() => { fetch(`${API_BASE_URL}/api/promotions`, { headers: { Authorization: `Bearer ${token}` } }) .then(r => r.json()) .then(setPromotions) .catch(console.error); }, [token]);
return ( <div> {promotions.map(p => ( <div key={p.id}>{p.title} — {p.discountPercent}% off</div> ))} </div> );}Rules to follow:
- Always use
API_BASE_URLfromconfig/api.ts— never hardcode a URL - Always pass the token from
useAuth()context for protected calls - Always handle loading and error states
Step 7: Wire into the dashboard (if admin feature)
Section titled “Step 7: Wire into the dashboard (if admin feature)”If your feature belongs in the admin dashboard, add it to Dashboard.tsx as a new tab following the existing tab pattern. The sidebar is in components/Dashboard/Layout/Sidebar.tsx.
Step 8: Add the route to App.tsx (if it’s a page)
Section titled “Step 8: Add the route to App.tsx (if it’s a page)”For new pages (not dashboard tabs), add a route in frontend/src/App.tsx:
import { PromotionsPage } from './pages/PromotionsPage';
// Inside AppRoutes:<Route path="/promotions" element={<SiteLayout><PromotionsPage /></SiteLayout>} />Use ProtectedRoute for authenticated-only pages.
Step 9: Verify both environments
Section titled “Step 9: Verify both environments”Before raising a PR, test:
- Local MySQL — run backend with
npm run dev, verify the feature works end-to-end - Cloudflare D1 (local sim) — run
npx wrangler devin the backend folder, test the same flow - Any raw SQL you wrote should be checked against the SQL dialect table
Checklist
Section titled “Checklist”- Migration SQL written and applied in both MySQL + D1
- Route uses
db.run/get/all(not raw mysql2 or D1) - Route uses parameterized queries
- Protected endpoints have
authenticateToken+ role guard - Generic error messages returned (no raw
err.messageto client) - Route registered in
index.js - Frontend uses
API_BASE_URL - Frontend handles loading + error states
- Tested against both MySQL locally and D1 via
wrangler dev