Skip to content

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.

Write out what your feature needs to store. Keep it simple:

-- backend/migrations/add_promotions.sql
CREATE 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 COLUMN guard pattern already in the codebase, or better yet, add a proper migration file.

Applying the migration:

Terminal window
# Local MySQL
mysql -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.sql

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 promotions
router.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 promotion
router.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/all from config/db.js — never import mysql2 or use D1 directly

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);
Terminal window
# Start backend
cd backend && npm run dev
# Test public endpoint
curl http://localhost:5001/api/promotions
# Test protected endpoint
curl -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;
}

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/ or src/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_URL from config/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.

Before raising a PR, test:

  1. Local MySQL — run backend with npm run dev, verify the feature works end-to-end
  2. Cloudflare D1 (local sim) — run npx wrangler dev in the backend folder, test the same flow
  3. Any raw SQL you wrote should be checked against the SQL dialect table
  • 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.message to 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