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
1-2. Set up Monorepo
Create a file pnpm-workspace.yaml in root.
packages:
- 'frontend'
- 'backend'
- 'shared'
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
1.4. Define Types and Values
// shared/src/types/languages.ts
export type Language = "javascript" | "go" | "c";
// shared/src/const/languages.ts
import { Language } from "../types/languages";
export const defaultLanguages: Language[] = ["javascript"];
export const languages: Language[] = ["javascript", "go", "c"];
2. Setup Backend
2-1. Set Up Backend/Nestjs Package
> npm i -g @nestjs/cli
> nest new backend
2-2. Reinstall Dependencies
> rm -rf backend/node_modules
> pnpm install
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
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;
}
}
// 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();
}
}
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();
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
└
...
3-2. Install dependences and Add Shared Package
> pnpm install
> pnpm add --filter frontend --workspace shared
. 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
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
You will see this error
Error: Cannot find module 'shared/src/const/languages'
Let's test frontend.
> pnpm run --filter frontend dev
It won't have any problems.
Let's build it.
> pmpm run --filter frontend build
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";
~~~~~~~
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
> cd shared
> pnpm exec tsc --init
{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"declaration": true,
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
5-2. Create index.ts
// shared/src/index.ts
export * from './const/languages';
export * from './types/languages';
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
// 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,
});
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"
}
}
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;
}
}
// 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();
}
}
6. Result
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


Top comments (0)