Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions examples/prisma-hyperdrive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Prisma + Hyperdrive on Cloudflare Workers

A full-stack CRUD example using [Prisma](https://www.prisma.io/) with [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for accelerated PostgreSQL access on Cloudflare Workers via vinext.

Demonstrates:
- Per-request Prisma client (avoids [alternating connection failures](https://github.com/cloudflare/vinext/issues/537))
- Hyperdrive connection pooling
- Server component data fetching
- Route handler CRUD API

## Prerequisites

- A PostgreSQL database (Neon, Supabase, or any provider)
- A Cloudflare account with Hyperdrive enabled

## Setup

1. Install dependencies:

```bash
pnpm install
```

2. Set your database URL:

```bash
echo 'DATABASE_URL="postgresql://user:pass@host:5432/db"' > .env
```

3. Create the database table:

```bash
pnpm db:push
```

4. Generate the Prisma client:

```bash
pnpm db:generate
```

5. Create a Hyperdrive config:

```bash
npx wrangler hyperdrive create my-db \
--connection-string="postgresql://user:pass@host:5432/db"
```

6. Copy the Hyperdrive ID into `wrangler.jsonc`:

```jsonc
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<paste-id-here>" }]
```

## Development

```bash
pnpm dev
```

Open http://localhost:5173 — the app fetches items from your database via Hyperdrive.

## Deploy

```bash
pnpm build
npx wrangler deploy
```

## How it works

### The per-request client pattern

Cloudflare Workers reuse isolates across requests, but each request has its own I/O context. A global `PrismaClient` singleton causes alternating failures because the connection pool from one request becomes invalid in the next.

`lib/db.ts` solves this with a short-lived cache:

```ts
let cached: PrismaClient | null = null;
let cachedAt = 0;

export function getPrisma(): PrismaClient {
const now = Date.now();
if (cached && now - cachedAt < 50) return cached;
const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString });
cached = new PrismaClient({ adapter: new PrismaPg(pool) });
cachedAt = now;
return cached;
}
```

Within a single request (~50ms), `getPrisma()` returns the same client. Across requests, a fresh client is created. Zero alternating failures.

### Hyperdrive

Hyperdrive pools and caches PostgreSQL connections at Cloudflare's edge. The connection string comes from the `HYPERDRIVE` binding in `wrangler.jsonc`, accessed via `import { env } from "cloudflare:workers"`.

### Route handlers use standard Request

vinext route handlers receive the Web standard `Request` object (not `NextRequest`). Use `new URL(request.url)` for search params:

```ts
const { searchParams } = new URL(request.url);
```
36 changes: 36 additions & 0 deletions examples/prisma-hyperdrive/app/api/items/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getPrisma } from "@/lib/db";

export async function GET() {
const prisma = getPrisma();
const items = await prisma.item.findMany({ orderBy: { createdAt: "desc" } });
return Response.json(items);
}

export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const contentType = request.headers.get("content-type") ?? "";

let title: string | null = null;

if (contentType.includes("application/json")) {
const body = await request.json();
title = body.title;
} else {
// Form submission
const formData = await request.formData();
title = formData.get("title") as string;
}

if (!title?.trim()) {
return Response.json({ error: "title is required" }, { status: 400 });
}

const prisma = getPrisma();
const item = await prisma.item.create({ data: { title: title.trim() } });

// Redirect back to home on form submit, return JSON for API calls
if (!contentType.includes("application/json")) {
return new Response(null, { status: 303, headers: { Location: "/" } });
}
return Response.json(item, { status: 201 });
}
14 changes: 14 additions & 0 deletions examples/prisma-hyperdrive/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const metadata = {
title: "Prisma + Hyperdrive on vinext",
description: "Full-stack example with Prisma, Hyperdrive, and Cloudflare Workers",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: "system-ui, sans-serif", maxWidth: 600, margin: "40px auto", padding: "0 16px" }}>
{children}
</body>
</html>
);
}
29 changes: 29 additions & 0 deletions examples/prisma-hyperdrive/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getPrisma } from "@/lib/db";

export default async function Home() {
const prisma = getPrisma();
const items = await prisma.item.findMany({ orderBy: { createdAt: "desc" } });

return (
<main>
<h1>Items ({items.length})</h1>
<form action="/api/items" method="POST">
<input name="title" placeholder="New item..." required style={{ padding: 8, marginRight: 8 }} />
<button type="submit" style={{ padding: "8px 16px" }}>Add</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((item) => (
<li key={item.id} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<span style={{ textDecoration: item.completed ? "line-through" : "none" }}>
{item.title}
</span>
<span style={{ color: "#999", fontSize: 12, marginLeft: 8 }}>
{item.createdAt.toLocaleDateString()}
</span>
</li>
))}
{items.length === 0 && <li style={{ color: "#999" }}>No items yet. Add one above.</li>}
</ul>
</main>
);
}
43 changes: 43 additions & 0 deletions examples/prisma-hyperdrive/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Per-request Prisma client for Cloudflare Workers.
*
* Workers reuse isolates across requests, but each request has its own
* I/O context. A global PrismaClient singleton causes alternating
* connection failures (success, fail, success, fail...) because the
* connection from Request A is invalid in Request B.
*
* Solution: create a fresh PrismaClient per request using a simple
* module-scoped cache with a short TTL. Within a single request,
* multiple calls to getPrisma() return the same client (fast).
* Across requests, the TTL ensures a fresh client is created.
*
* Once vinext merges per-request store support (#608), this can be
* simplified to use getRequestStore() instead of the TTL pattern.
*/
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import { env } from "cloudflare:workers";

let cached: PrismaClient | null = null;
let cachedAt = 0;

/**
* Get a PrismaClient scoped to the current request.
*
* Uses Hyperdrive for connection pooling — the connection string comes
* from the HYPERDRIVE binding defined in wrangler.jsonc.
*/
export function getPrisma(): PrismaClient {
const now = Date.now();
// 50ms TTL: within a single request, reuse the client.
// Across requests (>50ms apart), create a fresh one.
if (cached && now - cachedAt < 50) {
return cached;
Comment on lines +35 to +36

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop reusing Prisma client across requests

This time-based cache is not actually request-scoped: two different requests that arrive within 50ms (including concurrent requests in one Worker isolate) will receive the same PrismaClient. Since this example’s rationale is avoiding cross-request connection reuse, this condition can reintroduce the same class of failures under load and makes behavior nondeterministic by request timing.

Useful? React with 👍 / 👎.

}
const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString });
const adapter = new PrismaPg(pool);
cached = new PrismaClient({ adapter });
cachedAt = now;
return cached;
}
33 changes: 33 additions & 0 deletions examples/prisma-hyperdrive/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "vinext-prisma-hyperdrive",
"type": "module",
"private": true,
"scripts": {
"dev": "vp dev",
"build": "vp build",
"preview": "vp preview",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push"
},
"dependencies": {
"@vitejs/plugin-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"vite": "catalog:",
"vinext": "workspace:*",
"@vitejs/plugin-rsc": "catalog:",
"react-server-dom-webpack": "catalog:",
"@cloudflare/vite-plugin": "catalog:",
"wrangler": "catalog:",
"@prisma/client": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0",
"pg": "^8.13.0"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"vite-plus": "catalog:",
"prisma": "^7.0.0",
"@types/pg": "^8.11.0"
}
}
17 changes: 17 additions & 0 deletions examples/prisma-hyperdrive/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model Item {
id String @id @default(uuid())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
16 changes: 16 additions & 0 deletions examples/prisma-hyperdrive/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
15 changes: 15 additions & 0 deletions examples/prisma-hyperdrive/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import vinext from "vinext";
import { cloudflare } from "@cloudflare/vite-plugin";

export default defineConfig({
plugins: [
vinext(),
cloudflare({
viteEnvironment: {
name: "rsc",
childEnvironments: ["ssr"],
},
}),
],
});
10 changes: 10 additions & 0 deletions examples/prisma-hyperdrive/worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Cloudflare Worker entry point for vinext App Router.
*
* Uses the default vinext handler — no custom logic needed.
* Hyperdrive binding is configured in wrangler.jsonc and
* accessed via `import { env } from "cloudflare:workers"`.
*/

// @ts-expect-error -- virtual module resolved by vinext at build time
export { default } from "virtual:vinext-app-handler";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Import the supported vinext app entry

virtual:vinext-app-handler is not a virtual module that vinext resolves, so this export will fail during bundling with an unresolved import and the example cannot build/deploy. In packages/vinext/src/index.ts, the resolver only handles IDs like virtual:vinext-rsc-entry, virtual:vinext-app-ssr-entry, and virtual:vinext-app-browser-entry (or the non-virtual vinext/server/app-router-entry).

Useful? React with 👍 / 👎.

23 changes: 23 additions & 0 deletions examples/prisma-hyperdrive/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "prisma-hyperdrive",
"compatibility_date": "2026-03-01",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"preview_urls": true,
"assets": {
"not_found_handling": "none",
"binding": "ASSETS"
},
"images": {
"binding": "IMAGES"
},
// Hyperdrive accelerates PostgreSQL connections via connection pooling.
// Create one with: wrangler hyperdrive create my-db --connection-string="postgres://..."
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<your-hyperdrive-id>"
}
]
}