Skip to main content

Creating a Modular Monorepo, Frontend Meets Backend

· 2 min read
JavaScript Dev

Overview

Once the schema and core features were taking shape, I needed a scalable way to manage both frontend and backend in a single codebase. My goals were:

  • Reduce context switching
  • Share types between API and UI
  • Optimize for developer speed and clarity

I chose a monorepo setup using PNPM workspaces. This let me keep backend (NestJS), frontend (Next.js), and shared types/utilities in separate packages—but in one unified repository.


Monorepo Structure

Here's the high-level layout I landed on:

apps/
├── frontend/ # Next.js app (SSR, client, UI logic)
└── backend/ # NestJS API with Prisma
packages/
├── shared/ # Common interfaces, enums, helper functions
└── config/ # Shared tsconfig, eslint, env loaders
prisma/ # Central schema, migrations
scripts/ # Utility scripts for seeds, exports

The real power came from treating packages/shared as a dependency for both the backend and frontend apps. Types stayed in sync, and data shape mismatches were caught early.


Why PNPM Workspaces

I chose PNPM over alternatives (like Lerna, Turborepo) because:

  • It’s fast and simple to configure
  • Native support for symlinked local packages
  • Great dev experience when rebuilding or linking shared logic

I configured package.json like this:

{
"name": "rajnitireport",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}

And set up relative imports in each package via tsconfig.json.


Sharing Types Between Frontend and Backend

The biggest benefit came from using the shared package to define:

  • Prisma model-derived interfaces
  • Common DTOs used in API + UI
  • Constants like ResourceType, PollType, etc.

This pattern drastically reduced duplication and bugs.

// shared/types.ts
export type ResourceType = 'LEADER' | 'PARTY' | 'ELECTION';
export interface PollOption {
id: number;
label: string;
labelLocal?: string;
}
// backend: polls.service.ts
import { PollOption } from '@shared/types';

// frontend: PollCard.tsx
import { PollOption } from '@shared/types';

Practical Dev Benefits

  • Fast refactors: update types once, propagate everywhere
  • Cleaner imports and test coverage
  • Less mental load from jumping between folders or repos
  • Scoped builds and deployments (coming soon)

Takeaways

If you're building a fullstack product where both frontend and backend evolve together, a monorepo is worth the setup time. Sharing types and logic saved me hours of guesswork, especially during schema changes.

Up next: how I handled dynamic chart rendering using Recharts with party logos and election performance trends.