Making Prisma 7 Work with Shopify Session Storage
ShopifyPrisma

Making Prisma 7 Work with Shopify Session Storage

If you've upgraded to Prisma 7 and your Shopify session storage broke, here's the fix

When Prisma 7 was released, it introduced significant changes to how custom output paths work. While these changes improve the developer experience, they can break compatibility with packages that expect Prisma's client at the default location—like @shopify/shopify-app-session-storage-prisma. In this post, I'll walk through the problem we encountered, why it happens, and how we solved it using a symlink-based approach that maintains compatibility without modifying third-party packages.

What Happened

After upgrading to Prisma 7 and configuring a custom output path for our generated Prisma client, our Shopify app's session storage stopped working. We were seeing errors like: Cannot find module '@prisma/client' or its corresponding type declarations

Why It Happens

Prisma 7 changed how custom output paths work. When you configure a custom output path in your schema.prisma:

schema.prisma
1generator client {
2  provider = "prisma-client"
3  output   = "./generated"  // Custom path
4}

The Prisma client is generated at that custom location instead of the default node_modules/@prisma/client location.

However, @shopify/shopify-app-session-storage-prisma (and likely other packages) expects to find `PrismaClient` at the default location. When it tries to import from @prisma/client, it fails because the client isn't there.

The Challenge

We needed a solution that:

  • ✅ Works with Prisma 7's custom output paths
  • ✅ Doesn't require modifying third-party packages
  • ✅ Works across different package managers (npm, yarn, pnpm)
  • ✅ Is automated and doesn't require manual steps
  • ✅ Maintains type safety in our application code

The Solution: Symlinks to the Rescue

We solved this by creating symlinks from the default Prisma client location to our custom output directory. This allows packages expecting the default location to find the client, while our application code can still use the custom path for better organization.

How It Works

  1. Prisma generates the client at our custom path (`prisma/generated/`)
  2. A post-install script creates symlinks from @prisma/client/.prisma/client/defaultprisma/generated/
  3. Packages expecting the default location find the client via the symlink
  4. Our application code imports from the custom path for type safety

Implementation

Step 1: Configure Custom Output in Schema

First, we configured Prisma to output to our custom directory:

schema.prisma
1generator client {
2  provider = "prisma-client"
3  output   = "./generated"
4}

Step 2: Reconfigure prisma helper to pull from generated client:

In my Shopify app development process, I like to use SQLite as the database on my local computer, and then when I'm deploying, I'll migrate it to a Postgres database. This file can be simplified if you just want to use SQLite or Postgres.

typescript
1import "dotenv/config";
2import { PrismaClient } from "@/prisma/generated/client";
3import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
4import { PrismaPg } from "@prisma/adapter-pg";
5import { Pool } from "pg";
6
7declare global {
8  var __db__: PrismaClient;
9}
10
11let db: PrismaClient;
12
13const connectionString = process.env.DATABASE_URL;
14
15if (!connectionString) {
16  throw new Error("DATABASE_URL environment variable is not set");
17}
18
19// Determine database provider: use DB_PROVIDER env var, or auto-detect from DATABASE_URL
20const getDbProvider = (): "sqlite" | "postgresql" => {
21  // Explicit provider flag takes precedence
22  const explicitProvider = process.env.DB_PROVIDER?.toLowerCase();
23  if (explicitProvider === "sqlite" || explicitProvider === "postgresql") {
24    return explicitProvider;
25  }
26
27  // Auto-detect from DATABASE_URL
28  // SQLite: file: prefix or file path (e.g., file:./dev.sqlite or ./dev.sqlite)
29  if (
30    connectionString.startsWith("file:") ||
31    connectionString.startsWith("./") ||
32    connectionString.startsWith("/") ||
33    !connectionString.includes("://")
34  ) {
35    return "sqlite";
36  }
37
38  // PostgreSQL: postgres:// or postgresql:// prefix
39  if (
40    connectionString.startsWith("postgres://") ||
41    connectionString.startsWith("postgresql://")
42  ) {
43    return "postgresql";
44  }
45
46  // Default to SQLite for development
47  return process.env.NODE_ENV === "production" ? "postgresql" : "sqlite";
48};
49
50const dbProvider = getDbProvider();
51
52const getPrismaClient = () => {
53  if (dbProvider === "sqlite") {
54    // SQLite: connectionString is the file path
55    // Remove 'file:' prefix if present, ensure it's a proper file path
56    const filePath = connectionString.replace(/^file:/, "");
57    // PrismaBetterSqlite3 expects a config object with 'url' property
58    const adapter = new PrismaBetterSqlite3({ url: filePath });
59    return new PrismaClient({ adapter });
60  } else {
61    // PostgreSQL
62    const pool = new Pool({ connectionString });
63    const adapter = new PrismaPg(pool);
64    return new PrismaClient({ adapter });
65  }
66};
67
68if (process.env.NODE_ENV === "production") {
69  db = getPrismaClient();
70} else {
71  if (!global.__db__) {
72    global.__db__ = getPrismaClient();
73  }
74  db = global.__db__;
75}
76
77const prisma = db;
78
79export default prisma;
80

Step 3: Create the Symlink Script

The core of our solution is a script that creates symlinks after Prisma generates the client:

scripts/link-prisma-client.js
1import {
2  existsSync,
3  symlinkSync,
4  unlinkSync,
5  mkdirSync,
6  readlinkSync,
7  lstatSync,
8  rmSync,
9  readdirSync,
10} from "fs";
11import { dirname, resolve, relative } from "path";
12import { fileURLToPath } from "url";
13
14const __filename = fileURLToPath(import.meta.url);
15const __dirname = dirname(__filename);
16const projectRoot = resolve(__dirname, "..");
17const customClientPath = resolve(projectRoot, "prisma", "generated");
18
19// Find @prisma/client/.prisma/client directory
20// Works with npm, yarn, and pnpm
21let prismaClientDotPrismaPath = null;
22
23// Try direct path first (npm/yarn)
24const directPath = resolve(
25  projectRoot,
26  "node_modules",
27  "@prisma",
28  "client",
29  ".prisma",
30  "client"
31);
32
33if (existsSync(directPath)) {
34  prismaClientDotPrismaPath = directPath;
35} else {
36  // For pnpm, search in .pnpm directory
37  const pnpmDir = resolve(projectRoot, "node_modules", ".pnpm");
38  if (existsSync(pnpmDir)) {
39    const entries = readdirSync(pnpmDir);
40    for (const entry of entries) {
41      if (entry.includes("@prisma+client")) {
42        const candidatePath = resolve(
43          pnpmDir,
44          entry,
45          "node_modules",
46          ".prisma",
47          "client"
48        );
49        if (existsSync(candidatePath)) {
50          prismaClientDotPrismaPath = candidatePath;
51          break;
52        }
53      }
54    }
55  }
56}
57
58if (!prismaClientDotPrismaPath) {
59  console.warn("⚠ Could not find @prisma/client/.prisma/client directory");
60  process.exit(0);
61}
62
63const defaultClientPath = resolve(prismaClientDotPrismaPath, "default");
64const browserClientPath = resolve(prismaClientDotPrismaPath, "index-browser");
65
66// Helper function to create or update symlink
67function createSymlink(symlinkPath, targetPath, description) {
68  const parentDir = dirname(symlinkPath);
69  if (!existsSync(parentDir)) {
70    mkdirSync(parentDir, { recursive: true });
71  }
72
73  // Check if symlink already exists and is correct
74  try {
75    const stats = lstatSync(symlinkPath);
76    if (stats.isSymbolicLink()) {
77      const currentTarget = readlinkSync(symlinkPath);
78      const resolvedCurrent = resolve(dirname(symlinkPath), currentTarget);
79      const resolvedTarget = resolve(targetPath);
80      
81      // Normalize paths for comparison
82      if (
83        resolvedCurrent.replace(/\/+$/, "") ===
84        resolvedTarget.replace(/\/+$/, "")
85      ) {
86        console.log(`✓ Symlink already correct: ${description}`);
87        return; // Already correct, no need to update
88      }
89      // Symlink points to wrong place, remove it
90      unlinkSync(symlinkPath);
91    } else {
92      // Not a symlink, remove it
93      rmSync(symlinkPath, { recursive: true, force: true });
94    }
95  } catch (e) {
96    // File doesn't exist, that's fine - we'll create it
97  }
98
99  // Create relative symlink
100  const relativePath = relative(parentDir, targetPath);
101  try {
102    symlinkSync(relativePath, symlinkPath, "dir");
103    console.log(`✓ Created symlink: ${description}`);
104  } catch (error) {
105    if (error.code === "EEXIST" && existsSync(symlinkPath)) {
106      // Retry after removing existing file
107      rmSync(symlinkPath, { recursive: true, force: true });
108      symlinkSync(relativePath, symlinkPath, "dir");
109      console.log(`✓ Created symlink: ${description}`);
110    } else {
111      throw error;
112    }
113  }
114}
115
116// Create symlinks if custom client exists
117if (existsSync(customClientPath)) {
118  createSymlink(defaultClientPath, customClientPath, "default");
119  createSymlink(browserClientPath, customClientPath, "index-browser");
120} else {
121  console.warn(
122    `⚠ Custom Prisma client not found at ${customClientPath}. Run 'prisma generate' first.`
123  );
124}

Step 4: Automate with Package Scripts

We integrated the script into our build process:

package.json
1{
2  "scripts": {
3    "postinstall": "node scripts/link-prisma-client.js",
4    "setup": "prisma generate && node scripts/link-prisma-client.js && prisma migrate deploy",
5    "prisma:generate": "prisma generate && node scripts/link-prisma-client.js"
6  }
7}

Step 5: Configure Vite Alias for Deployment

While symlinks work great for local development, many deployment platforms (like Vercel) don't respect symlinks during the build process. To ensure your app works in production, we need to configure Vite to alias @prisma/client to our generated client location.

This approach:

  • Works on all deployment platforms - No symlink support required
  • Resolved at build time - Aliases are baked into the bundle
  • More reliable - Doesn't depend on filesystem features
  • Handles subpaths correctly - Allows runtime imports to resolve normally

Add a custom Vite plugin to your vite.config.ts:

vite.config.ts
1import { vitePlugin as remix } from "@remix-run/dev";
2import { installGlobals } from "@remix-run/node";
3import { defineConfig, type UserConfig, type Plugin } from "vite";
4import tsconfigPaths from "vite-tsconfig-paths";
5import { resolve, dirname } from "path";
6import { fileURLToPath } from "url";
7
8const __filename = fileURLToPath(import.meta.url);
9const __dirname = dirname(__filename);
10
11// Custom plugin to alias @prisma/client (but not subpaths) to generated client
12const prismaClientAliasPlugin = (): Plugin => {
13  // Point to the client.ts file directly for ESM/TypeScript resolution
14  const generatedClientPath = resolve(
15    __dirname,
16    "./prisma/generated/client.ts",
17  );
18
19  return {
20    name: "prisma-client-alias",
21    enforce: "pre",
22    resolveId(id, importer) {
23      // Only alias exact @prisma/client imports, not subpaths like @prisma/client/runtime/*
24      if (id === "@prisma/client") {
25        return generatedClientPath;
26      }
27      // Allow subpaths to resolve normally to the real @prisma/client package
28      if (id.startsWith("@prisma/client/")) {
29        return null; // Let Vite resolve normally
30      }
31      return null;
32    },
33  };
34};
35
36export default defineConfig({
37  // ... your existing server config ...
38  plugins: [
39    // Custom plugin to alias @prisma/client (but not subpaths) to generated client
40    // This ensures @shopify/shopify-app-session-storage-prisma can find PrismaClient
41    // at build time, making it work on Vercel without symlinks
42    // Subpaths like @prisma/client/runtime/* still resolve to the real package
43    prismaClientAliasPlugin(),
44    remix({
45      // ... your remix config ...
46    }),
47    tsconfigPaths(),
48  ],
49  // ... rest of your config ...
50});

Why this is important

The generated Prisma client itself imports from @prisma/client/runtime/*. These runtime imports need to resolve to the real package in node_modules, not the generated client. Our plugin only aliases the exact @prisma/client import, allowing subpaths to resolve normally.

With this configuration, your app will work both locally (using symlinks) and in production (using the Vite alias). The symlink script won't interfere with the Vite alias, and having both provides redundancy.

Conclusion

Upgrading to Prisma 7 with custom output paths doesn't have to break compatibility with existing packages. By combining symlinks for local development and Vite aliases for deployment, we maintained compatibility while keeping the benefits of custom output paths.