
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:
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
- Prisma generates the client at our custom path (`prisma/generated/`)
- A post-install script creates symlinks from @prisma/client/.prisma/client/default → prisma/generated/
- Packages expecting the default location find the client via the symlink
- 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:
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.
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;
80Step 3: Create the Symlink Script
The core of our solution is a script that creates symlinks after Prisma generates the client:
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:
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:
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.