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)