Home/Blog/From MVP to Scale: How We Built Track My Academy

From MVP to Scale: How We Built Track My Academy

By TrackMy Tech Team••Case Study•15 min read
A coach talking to young footballers in a huddle

Track My Academy, a management platform for sports academies, started in Chennai in 2024 with one academy that was buried in paper registers, missed fee payments and attendance confusion. A few of us were students at the time, and we decided to build something better as a late-night side project between study sessions. Like every product team, we needed to move fast enough to find out whether the idea worked, and we needed to build something that wouldn't fall over once real academies depended on it.

This isn't a "we built a unicorn in six weeks" story. Track My Academy is built by a student team of about seven people, it is 100% bootstrapped (no investors, no grants), and it is incubated at VELS Innovation Council in Chennai. This is an account of how the product went from the one academy that inspired it to a multi-tenant platform that coaches use every day: the technical decisions that worked, the ones that didn't, and what we'd change if we started again. The code below is simplified from what we run, but the patterns are the real ones.

The Problem We Set Out to Solve

Sports academies are an odd operational fit. They're part school, part gym and part events venue. The academy that got us started was a textbook case, and the owners we spoke to after it were running their academies on spreadsheets, WhatsApp groups and paper registers. Fees were tracked in one place and attendance in another. Performance tracking, if it existed at all, lived in a coach's notebook.

Before writing any code we talked to around twenty academy owners and coaches. The same problems kept coming up:

  • Fragmented operations. Most academies were juggling several disconnected tools, plus a lot of paper.
  • Admin eating into coaching time. Coaches spent a real share of their week on registers, fee follow-ups and messages instead of training.
  • Repetitive parent queries. Many parent messages asked for basic information (schedules, fees due, whether a child attended) that a system could answer.
  • No long-term view of athletes. Hardly anyone could look back over a player's development across a season.

Technically, that meant we eventually needed:

  • Fast attendance marking for many sessions running at the same time, mostly in the evening
  • A multi-tenant architecture with strict data isolation between academies
  • A mobile-first experience for coaches standing on a field with a phone
  • Flexible fee models (monthly memberships, packages, pay-per-session)
  • Reporting that academy owners would actually open

We didn't need all of that on day one, though.

Phase 1: The MVP

The first goal was modest: build something that could run the core workflows of the one academy that inspired the project. It took longer than we planned, as first versions usually do, especially when the team is fitting the work around classes and exams.

Tech stack

We picked the stack our team knew best: a React front end and a Node.js + Express API, with MongoDB as the database. Today the pieces look like this:

  • React front end, hosted on Vercel's edge network.
  • Node.js + Express back end, running on AWS (and Azure).
  • MongoDB (MongoDB Atlas) as the primary database: a flexible schema while requirements were still changing every week.
  • Supabase for authentication, issuing the JWTs our API verifies on every request.
  • Redis for caching (added later, see below), Razorpay for plan payments and Zoho Mail for transactional email.

React and Node.js were our team's strongest skills, which matters more than anything else at the MVP stage. In the first version we kept everything as simple as possible to run and debug, accepting scalability limits we didn't need to solve yet.

Three workflows, nothing else

  1. Student enrolment: basic records with parent contact details
  2. Attendance: a quick present/absent screen for coaches (scan-in check-in came later)
  3. Payments: manual fee entry and payment history

Attendance was the screen coaches used most, so it got the most care:

// components/AttendanceSheet.jsx
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import api from '../services/api';

export default function AttendanceSheet() {
  const { sessionId } = useParams();
  const [students, setStudents] = useState([]);
  const [attendance, setAttendance] = useState({});

  useEffect(() => {
    let ignore = false;

    async function load() {
      try {
        const { data } = await api.get(`/sessions/${sessionId}/students`);
        if (ignore) return;
        setStudents(data.students);
        setAttendance(data.previousAttendance ?? {});
      } catch (error) {
        console.error('Failed to fetch students:', error);
      }
    }

    load();
    return () => { ignore = true; }; // avoid setting state for a stale session
  }, [sessionId]);

  async function markAttendance(studentId, status) {
    const previous = attendance[studentId] ?? null;
    setAttendance(prev => ({ ...prev, [studentId]: status })); // optimistic update

    try {
      await api.post(`/sessions/${sessionId}/attendance`, { studentId, status });
    } catch {
      setAttendance(prev => ({ ...prev, [studentId]: previous })); // roll back
    }
  }

  return (
    <div className="attendance-sheet">
      <h2>Session Attendance</h2>
      {students.map(student => (
        <div key={student._id} className="student-row">
          <span>{student.name}</span>
          <div className="attendance-controls">
            {['present', 'absent'].map(status => (
              <button
                key={status}
                onClick={() => markAttendance(student._id, status)}
                className={attendance[student._id] === status ? 'active' : ''}
              >
                {status === 'present' ? 'Present' : 'Absent'}
              </button>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

The component is simple, but it tested the assumption the whole product rested on: a coach could take attendance for a full batch in a couple of minutes, quicker than calling out names from a register. If that hadn't been true, nothing else would have mattered.

What we got right

  • Mobile-first design. Most coach interactions happened on phones, so we designed for the phone and treated desktop as the secondary layout.
  • Optimistic updates. Marking attendance felt instant even on patchy mobile data, and failed requests rolled back cleanly.

What we ignored on purpose

  • Roles and permissions (everyone was effectively an admin)
  • Automated billing and invoicing
  • Analytics and reporting
  • Support for more than one academy
  • Any performance work beyond basic indexes

Phase 2: Real Users, Real Breakages

The first weeks with a live academy taught us more than all our planning had.

Evening peak timeouts. Most training sessions happen between roughly 6 and 8 PM, so almost all our traffic arrived in a two-hour window. Database queries began timing out at exactly that time. The fixes were unglamorous: indexes that matched the queries the attendance screen actually ran, and connection settings we set deliberately instead of leaving at whatever the defaults happened to be.

// db.js
await mongoose.connect(process.env.MONGODB_URI, {
  maxPoolSize: 50,                // sized for our peak, not left implicit
  serverSelectionTimeoutMS: 5000, // fail fast instead of hanging requests
  socketTimeoutMS: 45000,
});

// models/Attendance.js — matches "attendance for this session" lookups
attendanceSchema.index({ sessionId: 1, studentId: 1 }, { unique: true });

Slow lists on older phones. Coaches with budget Android phones said the attendance screen felt sluggish. Every tap re-rendered every row. We extracted a memoised row component and, just as important, passed it a stable callback:

import { memo, useCallback } from 'react';

const StudentRow = memo(function StudentRow({ student, status, onMark }) {
  return (
    <div className="student-row">
      <span>{student.name}</span>
      <button onClick={() => onMark(student._id, 'present', status)}>Present</button>
      <button onClick={() => onMark(student._id, 'absent', status)}>Absent</button>
    </div>
  );
});

// In AttendanceSheet: a stable function that only depends on sessionId,
// so memo can actually skip rows that didn't change.
const markAttendance = useCallback(async (studentId, status, previous = null) => {
  setAttendance(prev => ({ ...prev, [studentId]: status }));
  try {
    await api.post(`/sessions/${sessionId}/attendance`, { studentId, status });
  } catch {
    setAttendance(prev => ({ ...prev, [studentId]: previous }));
  }
}, [sessionId]);

{students.map(student => (
  <StudentRow
    key={student._id}
    student={student}
    status={attendance[student._id]}
    onMark={markAttendance} // not an inline arrow: that would defeat memo
  />
))}

Our first attempt wrapped the row in memo but still passed onMark={(s) => markAttendance(id, s)}, which creates a new function on every render, so memo never skipped anything. It's a common mistake. These days the React Compiler (1.0 shipped in 2025) does much of this memoisation automatically, but you still need to understand why a component re-renders.

The missing export button. An academy owner asked, reasonably, how to get the payment records to their accountant. We had built a complete payment-tracking flow with no export. We'd designed around our data model instead of the owner's month-end workflow. A CSV export took us an afternoon to build.

Before long the signal was clear enough. Coaches marked attendance consistently without being reminded, and the academy kept using the product. The feedback was about what was missing, not about whether the product was worth using. That told us the core idea held up and it was time to build the foundations properly.

Phase 3: Building for Many Academies

As more academies came on board through 2025, the original single-academy design started to show its limits. Academies weren't cleanly separated, the API had to scale beyond a single instance, and deployments needed to happen without downtime.

We refactored step by step rather than rewriting.

Multi-tenancy: shared infrastructure, isolated data

Every academy is a tenant. All tenants share the application and database, and every document carries a tenantId. The tenant is resolved once per request, after authentication, and the request is rejected if the user doesn't belong to it:

// middleware/tenantContext.js
export async function tenantContext(req, res, next) {
  try {
    // e.g. "acme.trackmyacademy.com" -> "acme"
    const slug = req.hostname.split('.')[0];
    const tenant = await Tenant.findOne({ slug, active: true }).lean();

    if (!tenant) {
      return res.status(404).json({ error: 'Academy not found' });
    }

    // Never trust a tenant identifier on its own: check membership.
    const isMember = req.user.memberships.some(
      m => m.tenantId.equals(tenant._id)
    );
    if (!isMember) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    req.tenant = tenant;
    req.tenantId = tenant._id;
    next();
  } catch (error) {
    next(error);
  }
}

app.use('/api', authenticate, tenantContext);

Attaching the tenant to the request doesn't scope anything automatically. You have to use it in every query. We went through every query by hand at first and then moved scoping into a shared data-access layer, so that leaving out the tenant filter became the hard thing to do. Every index now starts with tenantId:

// models/Student.js
const studentSchema = new mongoose.Schema({
  tenantId: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true },
  name: { type: String, required: true },
  email: { type: String },
  status: { type: String, enum: ['active', 'inactive'], default: 'active' },
}, { timestamps: true });

studentSchema.index({ tenantId: 1, email: 1 }, { unique: true, sparse: true });
studentSchema.index({ tenantId: 1, status: 1, createdAt: -1 });

We cover the trade-offs between shared-schema, schema-per-tenant and database-per-tenant designs in more depth in our multi-tenant SaaS guide.

Fixing N+1 queries

The session list was loading attendance one session at a time. A single aggregation that counts on the database side replaced dozens of round trips:

// Before: one query per session
const sessions = await Session.find({ tenantId });
for (const session of sessions) {
  session.attendees = await Attendance.find({ sessionId: session._id });
}

// After: one aggregation, counts computed in the database
const sessions = await Session.aggregate([
  { $match: { tenantId: new mongoose.Types.ObjectId(tenantId) } },
  {
    $lookup: {
      from: 'attendances',
      localField: '_id',
      foreignField: 'sessionId',
      pipeline: [
        { $group: {
            _id: null,
            total: { $sum: 1 },
            present: { $sum: { $cond: [{ $eq: ['$status', 'present'] }, 1, 0] } },
        } },
      ],
      as: 'stats',
    },
  },
  { $project: { name: 1, date: 1, stats: { $first: '$stats' } } },
]);

In current Mongoose versions ObjectId must be called with new. Grouping inside the $lookup pipeline means you never pull every attendance record into memory just to count it.

A small cache, used carefully

We added Redis for read-heavy endpoints such as dashboards and schedules:

// middleware/cache.js
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export const cache = (ttlSeconds = 300) => async (req, res, next) => {
  if (req.method !== 'GET') return next();

  // Scope by tenant AND user: responses can differ by role.
  const key = `cache:${req.tenantId}:${req.user.id}:${req.originalUrl}`;

  try {
    const hit = await redis.get(key);
    if (hit) return res.json(JSON.parse(hit));
  } catch {
    return next(); // cache down? serve from the database
  }

  const originalJson = res.json.bind(res);
  res.json = (body) => {
    if (res.statusCode < 400) {
      redis.set(key, JSON.stringify(body), 'EX', ttlSeconds).catch(() => {});
    }
    return originalJson(body);
  };
  next();
};

The cache key must include everything that can change the response. Key by URL alone and one academy's data can leak into another's. Leave out the user and a coach can see a cached admin view. Short TTLs plus invalidation on writes kept stale data from becoming a support issue. Between the indexes, the aggregation fixes and the cache, the evening-peak slowdowns went away.

Phase 4: Features and Front-End Performance

With the foundations in place, we could finally build what academies kept asking for, in roughly the order they now appear on our homepage:

  1. Attendance tracking with QR code, biometric or mobile scan-in check-in, and real-time absence alerts to coaches
  2. Fee collection with invoices and automated payment reminders
  3. Player reports that parents can view on their phone
  4. Training plans per batch
  5. Performance insights
  6. Mobile apps for Android and iOS
  7. Parent communication, replacing WhatsApp groups
  8. Certificates and ID cards

We rolled each one out to a few academies first behind per-tenant feature flags, which were simply a features object on the tenant record that the API checks.

On the front end, route-level code splitting with React.lazy and Suspense kept the first load small on mobile connections, so heavy screens like analytics only download when someone opens them.

We also added a service worker so the app shell and static assets load quickly and survive flaky connections at outdoor grounds:

// service-worker.js — stale-while-revalidate for static assets only
const CACHE = 'static-v2';

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Only same-origin GETs, and never API responses (tenant data on shared devices).
  if (request.method !== 'GET' || url.origin !== self.location.origin) return;
  if (url.pathname.startsWith('/api/')) return;

  event.respondWith((async () => {
    const cache = await caches.open(CACHE);
    const cached = await cache.match(request);

    const network = fetch(request)
      .then((response) => {
        if (response.ok) cache.put(request, response.clone());
        return response;
      })
      .catch(() => cached); // offline: fall back to whatever we have

    if (cached) {
      event.waitUntil(network); // refresh in the background
      return cached;
    }
    return network;
  })());
});

What We'd Do Differently

1. Spend longer on the data model

Our first schema was too flat. Adding relationships later, such as batches, coach assignments and fee plans, meant migrations we could have avoided with another week of modelling up front.

2. Add observability on day one

We added structured logging and monitoring in phase 3. Before that, when something broke in production we were guessing. Today we'd start with JSON logs to stdout (a library such as winston or pino works well) that carry tenantId, request ID and timing on every line, plus basic uptime and error-rate alerts. It takes an afternoon to set up and pays for itself the first time something breaks.

3. Put tenantId in every schema from the start

Adding multi-tenancy later was the most painful change we made. Even with one customer, it costs almost nothing to include the field and the index on day one.

4. Write tests before the first refactor

We wrote very few tests during the MVP, so our first big refactor broke things we didn't notice for days. A handful of tests for the core flows would have let us move faster. Here's the kind of test we now write, using MSW v2 to mock the API:

// AttendanceSheet.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import AttendanceSheet from '../components/AttendanceSheet';

const server = setupServer(
  http.get('*/sessions/:sessionId/students', () =>
    HttpResponse.json({
      students: [
        { _id: '1', name: 'Arjun' },
        { _id: '2', name: 'Meera' },
      ],
    })
  ),
  http.post('*/sessions/:sessionId/attendance', () =>
    HttpResponse.json({ success: true })
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('marks a student present', async () => {
  render(
    <MemoryRouter initialEntries={['/sessions/123']}>
      <Routes>
        <Route path="/sessions/:sessionId" element={<AttendanceSheet />} />
      </Routes>
    </MemoryRouter>
  );

  expect(await screen.findByText('Arjun')).toBeInTheDocument();

  const [presentButton] = screen.getAllByRole('button', { name: 'Present' });
  await userEvent.click(presentButton);

  expect(presentButton).toHaveClass('active');
});

The component reads sessionId from the URL, so the test renders it inside a router rather than passing a prop it never reads.

5. Write down the "why" as you go

We later found ourselves documenting features months after building them and trying to reconstruct why we'd made certain decisions. A short decision log, written at the time, would have saved hours.

Takeaways for Product Teams

  1. Validate before you scale. Don't optimise for 10,000 users when you have ten, but don't make choices that force a rewrite either.
  2. Choose boring technology. We picked React and Node because we knew them well. Familiar tools are quicker to build with and quicker to debug.
  3. Build for the workflow, not the database. The missing export button is the example we still think about.
  4. Take on technical debt deliberately. Skipping things to ship is fine if you write down what you skipped and plan when you'll come back to it.
  5. Treat performance as a feature. A slow app feels broken on a budget phone in a noisy ground, even when it technically works.

Where We Are Now

Track My Academy is live in production, with Android and iOS apps, and coaches use it every day. It covers scan-in attendance with absence alerts, fee collection, player reports for parents, batch training plans, performance insights, parent communication, and certificates and ID cards. Setup takes under 20 minutes. In 2026 the company behind it was incorporated as TrackMy Tech Pvt Ltd, and we still run the product ourselves, so every architecture decision above is one we live with.

The path from MVP to scale wasn't a straight line. It was a series of trade-offs between speed and sustainability. Ship something small that solves a real problem, watch how people use it, and pay back your shortcuts before they compound.

If you run an academy, we'd love for you to try Track My Academy or book a free demo. If you're building something similar, book a free consultation with TrackMy Tech and we'll be happy to compare notes.

←BACK TO BLOG
STAY UPDATED

MORE FROM THE PRODUCT BLOG

SEE ALL POSTS