This guide shows how to embed Prisma Studio in your application with Cloudflare D1 database support.
Cloudflare D1's Worker binding blocks introspection queries (sqlite_master, pragma_table_list, pragma_table_xinfo) that Prisma Studio needs to discover your schema. This results in errors like:
Error: not authorized
Use Cloudflare's D1 HTTP REST API for schema introspection queries, which doesn't have the same restrictions as the Worker binding. Regular data queries still use the fast D1 Worker binding.
npm install @prisma/[email protected]
# or
bun add @prisma/[email protected]Add these to your .env:
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id
D1_DATABASE_ID=your-d1-database-id # Get from: npx wrangler d1 listCreate src/routes/api/studio.$.ts (or equivalent for your framework):
import type { D1Database } from "@cloudflare/workers-types";
// Types for Prisma Studio BFF protocol
interface Query<T = Record<string, unknown>> {
parameters: readonly unknown[];
sql: string;
transformations?: Partial<Record<keyof T, "json-parse">>;
}
interface StudioBFFQueryRequest {
procedure: "query";
query: Query<unknown>;
}
interface StudioBFFSequenceRequest {
procedure: "sequence";
sequence: readonly [Query<unknown>, Query<unknown>];
}
type StudioBFFRequest = StudioBFFQueryRequest | StudioBFFSequenceRequest;
interface D1HttpResponse {
success: boolean;
result?: { results: unknown[] }[];
errors?: { code: number; message: string }[];
}
interface StudioEnv {
D1: D1Database;
CLOUDFLARE_ACCOUNT_ID: string;
CLOUDFLARE_API_TOKEN: string;
D1_DATABASE_ID: string;
}
// D1 HTTP API client - bypasses Worker binding restrictions
async function queryD1HttpApi(
accountId: string,
databaseId: string,
apiToken: string,
sql: string
): Promise<unknown[] | null> {
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({ sql }),
});
const data = (await response.json()) as D1HttpResponse;
if (!data.success || !data.result?.[0]?.results) return null;
return data.result[0].results;
}
// Fetch schema via HTTP API
async function fetchSchemaFromHttpApi(
accountId: string,
databaseId: string,
apiToken: string
): Promise<unknown[] | null> {
const tables = await queryD1HttpApi(
accountId, databaseId, apiToken,
`SELECT name, sql FROM sqlite_master WHERE type='table'
AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'
AND name != '_prisma_migrations'`
);
if (!tables) return null;
const schemaInfo = [];
for (const table of tables as { name: string; sql: string }[]) {
const columns = await queryD1HttpApi(
accountId, databaseId, apiToken,
`SELECT * FROM pragma_table_xinfo('${table.name}')`
);
const fks = await queryD1HttpApi(
accountId, databaseId, apiToken,
`SELECT * FROM pragma_foreign_key_list('${table.name}')`
);
const fkMap = new Map();
if (fks) {
for (const fk of fks as { from: string; table: string; to: string }[]) {
fkMap.set(fk.from, { table: fk.table, to: fk.to });
}
}
schemaInfo.push({
name: table.name,
sql: table.sql,
columns: (columns || []).map((col: any) => ({
name: col.name,
datatype: col.type,
default: col.dflt_value,
pk: col.pk,
computed: col.hidden,
nullable: col.notnull === 0 ? 1 : 0,
fk_table: fkMap.get(col.name)?.table || null,
fk_column: fkMap.get(col.name)?.to || null,
})),
});
}
return schemaInfo;
}
// Cache schema for 1 minute
let cachedSchema: unknown[] | null = null;
let cacheTime = 0;
function isIntrospectionQuery(sql: string): boolean {
const lower = sql.toLowerCase();
return lower.includes("pragma_table_list") ||
lower.includes("pragma_table_xinfo") ||
lower.includes("sqlite_schema") ||
lower.includes("sqlite_master");
}
async function getSchemaInfo(env: StudioEnv): Promise<unknown[]> {
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN || !env.D1_DATABASE_ID) {
throw new Error("D1 HTTP API credentials not configured");
}
if (cachedSchema && Date.now() - cacheTime < 60000) {
return cachedSchema;
}
const schema = await fetchSchemaFromHttpApi(
env.CLOUDFLARE_ACCOUNT_ID,
env.D1_DATABASE_ID,
env.CLOUDFLARE_API_TOKEN
);
if (!schema) throw new Error("Failed to fetch schema from D1 HTTP API");
cachedSchema = schema;
cacheTime = Date.now();
return schema;
}
async function executeD1Query<T>(
d1: D1Database,
query: Query<T>,
env: StudioEnv
): Promise<[Error] | [null, T[]]> {
// Intercept introspection queries → use HTTP API
if (isIntrospectionQuery(query.sql)) {
const schema = await getSchemaInfo(env);
return [null, schema as T[]];
}
// Regular queries → use D1 Worker binding (fast)
try {
const stmt = d1.prepare(query.sql);
const bound = query.parameters.length > 0
? stmt.bind(...query.parameters)
: stmt;
const result = await bound.all();
return [null, result.results as T[]];
} catch (error) {
return [error instanceof Error ? error : new Error(String(error))];
}
}
// POST handler - implement based on your framework
export async function handleStudioRequest(request: Request, env: StudioEnv) {
const body = await request.json() as StudioBFFRequest;
if (body.procedure === "query") {
const [error, results] = await executeD1Query(env.D1, body.query, env);
return Response.json(error ? [{ name: "Error", message: error.message }] : [null, results]);
}
if (body.procedure === "sequence") {
const [q1, q2] = body.sequence;
const [err1, res1] = await executeD1Query(env.D1, q1, env);
if (err1) return Response.json([[{ name: "Error", message: err1.message }]]);
const [err2, res2] = await executeD1Query(env.D1, q2, env);
if (err2) return Response.json([[null, res1], [{ name: "Error", message: err2.message }]]);
return Response.json([[null, res1], [null, res2]]);
}
return Response.json([{ name: "Error", message: "Invalid procedure" }], { status: 400 });
}import { useMemo } from "react";
import { Studio } from "@prisma/studio-core/ui";
import { createSQLiteAdapter } from "@prisma/studio-core/data/sqlite-core";
import { createStudioBFFClient } from "@prisma/studio-core/data/bff";
import "@prisma/studio-core/ui/index.css";
export function PrismaStudioPage() {
const adapter = useMemo(() => {
const executor = createStudioBFFClient({ url: "/api/studio" });
return createSQLiteAdapter({ executor });
}, []);
return (
<div style={{ height: "100vh" }}>
<Studio adapter={adapter} />
</div>
);
}/* Override Prisma Studio styles if needed */
.prisma-studio-wrapper {
height: 100vh;
width: 100%;
}- Schema Discovery: When Prisma Studio loads, it queries
sqlite_masterandpragma_table_xinfoto discover tables/columns - Interception: Our BFF detects these introspection queries and routes them to Cloudflare's D1 HTTP REST API
- Data Queries: Regular SELECT/INSERT/UPDATE/DELETE queries use the fast D1 Worker binding
- Caching: Schema info is cached for 1 minute to reduce HTTP API calls
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Prisma Studio │────▶│ BFF Endpoint │────▶│ D1 Worker API │
│ (Frontend) │ │ /api/studio │ │ (Data queries) │
└─────────────────┘ └────────┬─────────┘ └─────────────────┘
│
│ Introspection
▼ queries only
┌──────────────────┐
│ D1 HTTP REST │
│ api.cloudflare │
└──────────────────┘
Inspired by Drizzle's d1-http driver approach.
relevant issue: prisma/prisma#22184
In my setup I'm using Tanstack Start + Alchemy.run + Prisma studio + oRPC and this worked for me
special thanks to my LLM buddy. :)