Introduction
I built a Formula 1 application to manage Teams, Tracks, Drivers, and Races, starting as a monolithic Next.js + Payload CMS app. As my needs grew, I transitioned to a microservices architecture for better scalability, modularity, and autonomy. This article details my process—folder structure, code changes, challenges, and deployment—using my F1 app as an example.
The Monolith: Initial Setup
I began with a monolithic app using create-payload-app, combining Next.js (frontend) and Payload CMS (backend) in a single codebase. It managed Teams, Tracks, Drivers, and Races, with a Dashboard for analytics, and was containerized with Docker for client-specific deployments. Client access was controlled via NEXT_PUBLIC_ENABLED_ROUTES (e.g., Client 1: Dashboard, Tracks; Client 2: Teams, Tracks).
Initial Folder Structure
f1-app/ ├── src/ │ ├── app/ │ │ ├── (frontend)/ # Next.js UI │ │ │ ├── components/ # Reusable UI components │ │ │ ├── drivers/ # Drivers pages │ │ │ ├── races/ # Races pages │ │ │ ├── teams/ # Teams pages │ │ │ └── tracks/ # Tracks pages │ │ ├── (payload)/ # Payload CMS │ │ │ ├── admin/ # Admin panel │ │ │ └── api/ # API endpoints │ │ └── public/ # Static assets ├── collections/ # Payload collections (Teams, Tracks, Drivers, Races) ├── docker-compose.yml # Docker orchestration ├── Dockerfile # Monolith container ├── package.json # Dependencies └── tsconfig.json # TypeScript config
Monolith Pros and Cons
- Pros: Fast development, simple deployment (single container), unified codebase.
- Cons: Tight coupling, scalability issues, slow builds as the app grew.
Monolith Docker Setup
I used a single docker-compose.yml for a Next.js/Payload container and Postgres database
Transition to Microservices
To address scalability and modularity, I extracted Teams and Drivers into standalone Payload CMS microservices, keeping Next.js as the frontend. Each microservice got its own codebase, database schema, and container.
Step 1: Microservices Folder Structure
I restructured the project to support microservices:
f1-app/ ├── services/ │ ├── teams/ │ │ ├── src/ │ │ │ ├── collections/ # Teams collection │ │ │ ├── payload.config.ts # Payload config │ │ ├── Dockerfile # Teams service container │ │ ├── package.json # Service dependencies │ │ └── tsconfig.json # TypeScript config │ ├── drivers/ │ │ ├── src/ │ │ │ ├── collections/ # Drivers collection │ │ │ ├── payload.config.ts │ │ ├── Dockerfile │ │ ├── package.json │ │ └── tsconfig.json ├── frontend/ │ ├── src/ │ │ ├── app/ │ │ │ ├── (frontend)/ # Next.js UI │ │ │ ├── components/ │ │ │ ├── drivers/ │ │ │ ├── races/ │ │ │ ├── teams/ │ │ │ └── tracks/ │ │ └── public/ │ ├── package.json │ ├── tsconfig.json │ └── Dockerfile ├── docker-compose.yml # Orchestrates all services └── .env # Environment variables
Step 2: Dockerizing Microservices
Each service was containerized with its own Dockerfile:
# services/teams/Dockerfile FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3001 CMD ["npm", "run", "start"]
I updated docker-compose.yml to orchestrate services and a shared Postgres database:
version: '3.8' services: teams-service: build: ./services/teams ports: - "3001:3001" environment: - DATABASE_URL=postgres://postgres:postgres@db:5432/teams_db drivers-service: build: ./services/drivers ports: - "3002:3002" environment: - DATABASE_URL=postgres://postgres:postgres@db:5432/drivers_db frontend: build: ./frontend ports: - "3000:3000" environment: - NEXT_PUBLIC_ENABLED_ROUTES=dashboard,teams,tracks,drivers,races - NEXT_PUBLIC_TEAMS_API=http://teams-service:3001/api - NEXT_PUBLIC_DRIVERS_API=http://drivers-service:3002/api db: image: postgres:15 ports: - "5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_MULTIPLE_DATABASES=teams_db,drivers_db volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:
Step 3: Updating the Next.js Frontend
I updated the frontend to fetch data from microservices:
// frontend/src/app/teams/page.tsx
async function getTeams() {
const res = await fetch(`${process.env.NEXT_PUBLIC_TEAMS_API}/teams`, { cache: 'no-store' });
return res.json();
}
export default async function TeamsPage() {
const teams = await getTeams();
return (
<div>
<h1>Teams</h1>
<ul>
{teams.docs.map((team: any) => (
<li key={team.id}>{team.name}</li>
))}
</ul>
</div>
);
}
Step 5: Deployment
I deployed using Docker Compose for development and plan to use Kubernetes for production:
- Build and Test Locally:
docker-compose up --build - Production Deployment: Push images to a registry (e.g., Docker Hub), use Kubernetes for orchestration, scaling, and load balancing, and set up CI/CD with GitHub Actions for automated builds and deployments.
- Environment Variables: Stored in
.envfor local dev, managed via Kubernetes secrets in production.
Conclusion
The migration from a monolithic architecture to microservices for the F1 application demonstrates the transformative potential of modular design in modern software development. By decomposing a tightly coupled system into independent, scalable services, the application achieved greater flexibility, improved maintainability, and the ability to scale components based on demand. While the transition introduced new challenges such as inter-service communication, latency, and deployment complexity the benefits of modularity, team autonomy, and scalability far outweigh the initial overhead.