Resolving a Prisma Generation Issue with PNPM workspaces

The Project

I recently started work on a project which uses PNPM as its primary package manager, and is structured as a monorepo.

PNPM makes this really easy to do, sharing of dependencies and local packages is seamless. And adding Turborepo to the mix makes things like build and test parallelisation really easy as well.

Following the Turborepo guide for adding Prisma, I setup a package called database which re-exports the PrismaClient from prisma/client. This package could then be consumed in any of the other packages within the monorepo.

The app itself is a Remix app called ui. Remix uses Vite for builds, so any bundling of application code is handled there.

Deploying with Docker

When it comes to deployments for the project, I’m using a custom Docker image which is pushed to Github and eventually deployed to Railway.

The PNPM documentation has details on how to setup a custom Docker image, but if you use corepack you can skip the requirement for a specific tailored pnpm flavoured Docker image.

Here’s the Dockerfile I’m using (located at the root of the monorepo)

FROM node:22-slim AS base

ARG DATABASE_URL

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

RUN corepack enable
RUN apt-get update -y && apt-get install -y openssl

FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run --filter=database generate
RUN pnpm run --filter=database deploy:prod
RUN pnpm run --filter=ui build

EXPOSE 8000
CMD [ "pnpm", "--filter=ui", "start" ]

Some notable additions to the Dockerfile from the PNPM documentation

Prisma Client Generation

Prisma is slightly unique in that there is a code generation step which needs to be performed before the application can be run. Your local schema.prisma file is read by Prisma, and a Typescript package is generated and placed within the node_modules/@prisma/client folder.

The Prisma documenation does a good job of explaining all of this, but in essence you have to run the following command after node modules has been installed, and after any schema changes have been made.

prisma migrate

Explicit Output Location

There is also a way of explicitly stating where the generated client should be placed which can be useful, for instance when working with multiple Prisma schemas in a single monorepo.

generator client {
  provider = "prisma-client-js"
  output = "./src/generated/prisma"
}

The Issue

Ok so onto the actual issue. The UI builds fine locally and in Docker. The Prisma Client is generated, and the database is deployed. When starting the application container however, a painful Prisma Client error is thrown.

Error: Cannot find module '.prisma/client/default'

Following the stack trace, I can see in the node modules folder for the Prisma client located at packages/database/node_modules/@prisma/client that the .prisma folder is indeed missing. Same with the apps/ui/node_modules/@prisma/client. But yet running the app locally outside of Docker works just fine.

.
├── apps
│   └── ui
│       └── node_modules
│           └── @prisma
│               └── client
|                   └── .prisma ❌ Not found
├── packages
│   └── database
│       └── node_modules
│           └── @prisma
│               └── client
|                   └── .prisma ❌ Also not found 👿

Attempts to resolve

When debugging I did come across other people[1][2] with a similar issue, but no clear steps for resolution.

Bundling Server Side code

One dead-end I explored was to bundle all server side code together, and marking non of the dependencies as external. But to cut a long story short, this was not a viable solution for me.

Prisma needs to be external, as it wraps a Rust binary which needs to be loaded by the process at runtime.

The Resolution

In the end, PNPM workspaces were the issue.

When running the prisma generate command, the Prisma Client location is actually not the node_modules/@prisma/client folder, but the root of the workspace, inside PNPM’s special .pnpm cache folder.

.
├── node_modules
│   └── .pnpm
│       └── @[email protected][email protected]
│           └── node_modules
|               └── .prisma ✅ Found

Bingo. So it exists somewhere, just not where the running container expects it to be.

Update Vite’s Build Config

Vite has a resolve.alias option which can be used to redirect imports to a different location. This is what I needed to do to get the build to pick up the Prisma Client from the cache location.

I started by hardcoding the following

alias: {
  ".prisma/client/default":
    "../../node_modules/.pnpm/@[email protected][email protected]/node_modules/@prisma/client/default.js",
},

…and it worked!

Full credit to Jolg42’s issue comment for the idea.

Later in the same issue thread, leifermendez left a suggestion which generalised the Vite build config, leaving me with the following:

import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { createRequire } from "module";
import path from "path";

// Create a require function for ES modules
const require = createRequire(import.meta.url);

/**
 * Resolves the path to the Prisma client file.
 * @param {string} replacement - The part of the path to replace.
 * @param {string} toReplace - The new path to replace with.
 * @returns {string} - The resolved path.
 */
const resolvePrismaClientPath = (
  replacement: string,
  toReplace: string
): string => {
  const regex = new RegExp(`@prisma[\\\\/]client[\\\\/]${replacement}\\.js`);
  const resolvePath =
    replacement !== "default"
      ? `@prisma/client/${replacement}`
      : "@prisma/client";
  const resolvedPath = require.resolve(resolvePath).replace(regex, toReplace);
  return resolvedPath;
};

const prismaIndexPaths = Object.fromEntries(
  ["index-browser", "edge", "default"].map((key) => [
    key,
    path.normalize(
      path.relative(
        process.cwd(),
        resolvePrismaClientPath(
          key,
          path.join(".prisma", "client", `${key}.js`)
        )
      )
    ),
  ])
);

export default defineConfig({
  plugins: [
    tsconfigPaths(),
    remix({
      basename: "/",
      buildDirectory: "build",
      future: {
        /* any enabled future flags */
      },
      appDirectory: "src",
    }),
  ],
  ssr: {
    external: ["bcrypt"],
  },
  resolve: {
    alias: {
      ".prisma/client/default": prismaIndexPaths.default,
      ".prisma/client/index-browser": prismaIndexPaths.indexBrowser,
      ".prisma/client/edge": prismaIndexPaths.edge,
    },
  },
});

Problem solved!