Sharing code between Next.js and Expo in a pnpm monorepo: the setup I use for CV Builder
Most monorepos die at the boundary between web and mobile. Next.js wants its bundler to do everything. Expo wants Metro to do everything. They disagree about almost every tooling choice. Yet the value of sharing schemas, types, and API clients between web and mobile is too high to give up.
This is the layout I landed on for CV Builder, a freemium SaaS that ships a Next.js web app and an Expo mobile app on top of a single Supabase backend.
Folder layout
cv-builder/
├── apps/
│ ├── web/ Next.js 16
│ └── mobile/ Expo SDK 54
├── packages/
│ ├── schemas/ Zod + types
│ ├── supabase/ client factory + generated types
│ └── ui/ platform agnostic primitives (rare)
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.base.json
The folder split looks normal. The discipline is what makes it work.
Rule one: packages export source, not built artifacts
Every internal package has "main": "./src/index.ts" and no build step. Next.js and Expo both compile TypeScript from node_modules when you tell them to. So I tell them to.
In next.config.ts:
const nextConfig = {
transpilePackages: ['@cv/schemas', '@cv/supabase'],
}In metro.config.js:
const config = getDefaultConfig(__dirname)
config.watchFolders = [path.resolve(__dirname, '../../packages')]
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, '../../node_modules'),
]
module.exports = configNo tsc in the package. No build outputs to keep in sync. The bundler compiles the shared source on demand. When you change a Zod schema, both apps see the change on next reload.
Rule two: keep platform aware code out of shared packages
A schema is platform agnostic. A Supabase client is mostly platform agnostic if you let the consumer pass the fetch implementation. UI is almost never platform agnostic and should usually live inside the app, not the package.
The @cv/ui package in CV Builder is tiny on purpose. Real components stay in apps/web/components or apps/mobile/components. The shared package is for things like a formatCurrency helper or a typed icon mapping, not a Button.
Rule three: a single tsconfig source of truth
tsconfig.base.json at the root sets strict mode, path aliases, and the lib target. Every app and package extends it. When TypeScript settings drift between apps, you spend a week chasing types that are correct in one half of the repo and wrong in the other.
What I actually share
In CV Builder the shared packages carry:
- Zod schemas for CV records, education entries, work entries, AI prompt payloads.
- Supabase generated database types and a client factory.
- Pure utility functions: ATS scoring math, slug generators, date formatting.
That is enough. Trying to share more becomes a bundler argument I do not want to have.
What I would tell hiring managers
A clean monorepo is not the same as a complex monorepo. The win is engineers stop worrying about whether the mobile app and the web app agree on what a record looks like, because the schema is in one place. Everything else is plumbing.
If you are hiring for a remote role that ships both web and mobile, ask candidates to draw the package boundary. The answer tells you whether they have actually shipped one.
Md. Tausif Hossain leads engineering at DevTechGuru and ships SaaS independently as TechnicalBind. He is open to remote Senior and Staff roles. Reach him at tausif1337.dev.
Newsletter
Get new posts in your inbox.
Honest essays on engineering, leadership, and the things I’m figuring out. No spam, ever.