DEV Community

Cover image for Sharing Types and Values Across Frontend and Backend Using a PNPM Monorepo (React + NestJS)
SeongKuk Han
SeongKuk Han

Posted on • Edited on

1

Sharing Types and Values Across Frontend and Backend Using a PNPM Monorepo (React + NestJS)

Here's a video. – Sorry for my English. Just look at the code and what I did.

No edit, only for your reference.

At the end of the video, the frontend wasn't able to import in a subpath, and the error was gone at some point, even though the code was the same. I have a doubt that there might have been a cache issue or a file encoding issue (it happened during the video once). You can check the code; I attached the GitHub repository URL at the bottom of this post. Thanks.


pnpm is my favorite package manager. I recently started a side project and decided to use pnpm as a package manager and structure it as a monorepo. If you work alone on both frontend and backend, you might find situations where you need the same types and values on both sides. If you set up your project as a monorepo, you can add a new package and share them instead of defining them on both sides.

In modern TypeScript, you can simply add a package and import what you want. However, you may encounter a situation where you can't import values from the shared package because they use a different module system.

In my case, NestJS uses commonjs as a module system, and Vite uses a modern module system.

In this post, I will show you an example of how to share a package between projects that use different module systems in a monorepo.

I will set up a React project using Vite and a backend server using NestJS.

The example will be simple.

The shared package has a Language type and values languages and defaultLanguages.

There is an API endpoint GET /languages, which returns languages.

The frontend requests the API and updates the state with the result from the endpoint. Before requesting the API, the state has defaultLanguages from the shared package as a default value.

Both sides use the Language type.

Let's get started.


1. Setup Shared

1-1. Set up Project

> mkdir pnpm_mono_shared
> cd pnpm_mono_shared
> pnpm init
Enter fullscreen mode Exit fullscreen mode

1-2. Set up Monorepo

Create a file pnpm-workspace.yaml in root.

packages:
  - 'frontend'
  - 'backend'
  - 'shared'
Enter fullscreen mode Exit fullscreen mode

Add those three projects.

1-3. Create Shared Package

> mkdir -p shared/src/types
> touch shared/src/types/languages.ts
> mkdir -p shared/src/const
> touch shared/src/const/languages.ts
> cd shared
> pnpm init
Enter fullscreen mode Exit fullscreen mode

1.4. Define Types and Values

// shared/src/types/languages.ts
export type Language = "javascript" | "go" | "c";
Enter fullscreen mode Exit fullscreen mode
// shared/src/const/languages.ts
import { Language } from "../types/languages";

export const defaultLanguages: Language[] = ["javascript"];

export const languages: Language[] = ["javascript", "go", "c"];
Enter fullscreen mode Exit fullscreen mode

2. Setup Backend

2-1. Set Up Backend/Nestjs Package

> npm i -g @nestjs/cli
> nest new backend
Enter fullscreen mode Exit fullscreen mode

2-2. Reinstall Dependencies

> rm -rf backend/node_modules
> pnpm install
Enter fullscreen mode Exit fullscreen mode

The initial node_modules directory is generated by Nest CLI, so you need to delete and reinstall it to use it in our monorepo.

2-3. Add Shared Package

> pnpm add --filter backend --workspace shared
Enter fullscreen mode Exit fullscreen mode

The --filter option is used to select a project, and when combined with --workspace, it adds the package by searching within our workspace.

2-4. Add getLanguages Endpoint

// app.service.ts
import { Injectable } from '@nestjs/common';
import { languages } from 'shared/src/const/languages'

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }

  getLanguages() {
    return languages;
  }
}
Enter fullscreen mode Exit fullscreen mode
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Language } from 'shared/src/types/languages';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('languages')
  getLanguages(): Language[] {
    return this.appService.getLanguages();
  }
}
Enter fullscreen mode Exit fullscreen mode

2-5. Enable CORS

Enable CORS to accept requests from the frontend, which is hosted on a different domain.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

3. Setup Frontend

3-1. Set Up React Package

> % pnpm create vite
[│
◇  Project name:
│  frontend
│
◆  Select a framework:
│  ○ Vanilla
│  ○ Vue
│  ● React
│  ○ Preact
│  ○ Lit
│  ○ Svelte
│  ○ Solid
│  ○ Qwik
│  ○ Angular
│  ○ Marko
│  ○ Others
└
...
Enter fullscreen mode Exit fullscreen mode

3-2. Install dependences and Add Shared Package

> pnpm install
> pnpm add --filter frontend --workspace shared
Enter fullscreen mode Exit fullscreen mode

. 3-3. Write App

import { useEffect, useState } from 'react'
import { defaultLanguages } from 'shared/src/const/languages';

function App() {
  const [languages, setLanguages] = useState(defaultLanguages);

  useEffect(() => {
    setTimeout(async () => {
      const res = await fetch('http://localhost:3000/languages');
      const languages = await res.json();
      setLanguages(languages);
    }, 3000);
  }, []);

  return (
    <>
      {languages}
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

It displays the defaultLanguages from the shared package. After 3 seconds, it requests the getLanguages endpoint and updates the state.


4. Test

Backend

> pnpm run --filter backend start:dev
Enter fullscreen mode Exit fullscreen mode

You will see this error

Error: Cannot find module 'shared/src/const/languages'
Enter fullscreen mode Exit fullscreen mode

Let's test frontend.

> pnpm run --filter frontend dev
Enter fullscreen mode Exit fullscreen mode

It won't have any problems.

Let's build it.

> pmpm run --filter frontend build
Enter fullscreen mode Exit fullscreen mode

You will see this type error.

../shared/src/const/languages.ts:1:10 - error TS1484: 'Language' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

1 import { Language } from "../types/languages";
           ~~~~~~~
Enter fullscreen mode Exit fullscreen mode

As mentioned, changing it to import type solves the problem.

However, the issue is on the backend — it still doesn't work. Let's fix that in the next step.


5. Compile Shared

5-1. Set up Typescript

> pnpm add --filter shared typescript -D
Enter fullscreen mode Exit fullscreen mode
> cd shared
> pnpm exec tsc --init
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "declaration": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

5-2. Create index.ts

// shared/src/index.ts
export * from './const/languages';
export * from './types/languages';
Enter fullscreen mode Exit fullscreen mode

It will serve as a fallback option for modules that don't support the exports field in package.json.

5-3. Set Up Bundler tsup

You can compile with different options manually, but setting it up is a pain. tsup makes this easier.

> pnpm add --filter shared -D tsup
Enter fullscreen mode Exit fullscreen mode
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts', 'src/const/languages.ts', 'src/types/languages.ts'],
  format: ['esm', 'cjs'],
  outDir: 'dist',
  dts: true,
  clean: true,
});
Enter fullscreen mode Exit fullscreen mode

5-3. Update package.json

{
  "name": "shared",
  "version": "1.0.0",
  "scripts": {
    "build": "tsup",
    "build:watch": "nodemon --watch src --ext ts --exec 'pnpm run build'"
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": [
    "dist"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    },
    "./const/languages": {
      "import": "./dist/const/languages.mjs",
      "require": "./dist/const/languages.js"
    },
    "./types/languages": {
      "import": "./dist/types/languages.mjs",
      "require": "./dist/types/languages.js"
    }
  },
  "packageManager": "pnpm@10.6.4",
  "devDependencies": {
    "tsup": "^8.5.0",
    "typescript": "^5.8.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

In the modern module system, it uses the exports field, and you must explicitly define each path. This prevents the use of **/* for security reasons.

The build:watch script compiles the code whenever there are file changes, but you need to install nodemon first. I included it here as an example.

5-4. Update import paths

// backend/app.service.ts
import { Injectable } from '@nestjs/common';
import { languages } from 'shared';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }

  getLanguages() {
    return languages;
  }
}
Enter fullscreen mode Exit fullscreen mode
// backend/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Language } from 'shared';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('languages')
  getLanguages(): Language[] {
    return this.appService.getLanguages();
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Result

Before commands

After commands


Conclusion

I discovered tsup while writing this post. Initially, I tried to configure everything myself and eventually got it working, but it was too complicated. So, I decided to use a bundler — tsup, which was recommended by ChatGPT.

I hope you found this helpful.

Happy coding!


You can find the code here

Github: https://github.com/hsk-kr/pnpm-monorepo-example

Runner H image

Bitcoin Intelligence Daily Brief - Automated Market & Industry Intelligence

Check out this winning submission to the Runner H "AI Agent Prompting" Challenge. 👀

Read more →

Top comments (0)

Warp.dev image

Warp is the highest-rated coding agent—proven by benchmarks.

Warp outperforms every other coding agent on the market, and gives you full control over which model you use. Get started now for free, or upgrade and unlock 2.5x AI credits on Warp's paid plans.

Download Warp

👋 Kindness is contagious

Explore this practical breakdown on DEV’s open platform, where developers from every background come together to push boundaries. No matter your experience, your viewpoint enriches the conversation.

Dropping a simple “thank you” or question in the comments goes a long way in supporting authors—your feedback helps ideas evolve.

At DEV, shared discovery drives progress and builds lasting bonds. If this post resonated, a quick nod of appreciation can make all the difference.

Okay