diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index cf475e71a27..4cf1c94aea8 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -36,6 +36,7 @@ jobs: uses: lycheeverse/lychee-action@v2 with: # prisma.io/extensions ships with PR #8232; drop this exclude once it is live. + # /compare/neon, /compare/planetscale, and /compare/supabase ship with PR #8516; drop that exclude once they are live. args: >- --cache --cache-exclude-status 429,500,502,503,504 @@ -56,6 +57,7 @@ jobs: --exclude 'https://pris.ly/vercel-app-demo-live' --exclude 'https://app-deploy-demo.prisma.io' --exclude '^https://www\.prisma\.io/extensions' + --exclude '^https://www\.prisma\.io/compare/(neon|planetscale|supabase)$' --accept 200,201,204,301,302,303,307,308,403,429 ${{ steps.changed-docs.outputs.all_changed_files }} output: lychee/out.md @@ -90,6 +92,7 @@ jobs: --exclude 'https://pris.ly/vercel-app-demo-live' --exclude 'https://app-deploy-demo.prisma.io' --exclude '^https://www\.prisma\.io/extensions' + --exclude '^https://www\.prisma\.io/compare/(neon|planetscale|supabase)$' --accept 200,201,204,301,302,303,307,308,403,429 ${{ steps.changed-docs.outputs.all_changed_files }} output: lychee/out-retry.md diff --git a/.github/workflows/site-test.yml b/.github/workflows/site-test.yml index 8a4de3388a3..56d99e0b9be 100644 --- a/.github/workflows/site-test.yml +++ b/.github/workflows/site-test.yml @@ -43,5 +43,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --filter "site..." + # Comparison pages import @prisma/eclipse, whose package entry is the + # built dist. The install does not produce it. + - name: Build Eclipse + run: pnpm --filter @prisma/eclipse build + - name: Run unit tests run: pnpm --filter=site test diff --git a/apps/blog/content/blog/neon-vs-prisma-postgres-app-hosting-in-one-place/index.mdx b/apps/blog/content/blog/neon-vs-prisma-postgres-app-hosting-in-one-place/index.mdx index 265d75a27b0..033e46f8ad3 100644 --- a/apps/blog/content/blog/neon-vs-prisma-postgres-app-hosting-in-one-place/index.mdx +++ b/apps/blog/content/blog/neon-vs-prisma-postgres-app-hosting-in-one-place/index.mdx @@ -64,7 +64,7 @@ The [comparison table](#how-the-four-compare-with-neon) uses these ten criteria, - What it does: [Prisma Postgres](https://www.prisma.io/docs/postgres) is managed Postgres, run by Prisma: PostgreSQL 17 with PgBouncer pooling included, reached by any Postgres client through a connection string. [Prisma Compute](https://www.prisma.io/docs/compute) runs TypeScript services (Node.js, Bun or Next.js) next to it in the same region, chosen from six, generally available since August 2026. With [Prisma Composer](https://www.prisma.io/docs/composer) you describe your whole app in TypeScript, each service and the database it depends on; `npx prisma dev module.ts` runs it on your laptop, and `npx prisma deploy module.ts` provisions the services on Prisma Compute and the database on Prisma Postgres. - Pricing: Free $0, no credit card, no usage billing: 1M Compute requests and 200,000 database operations a month, 50 databases. Starter $10 base plus metered compute: 5M requests and 1M operations included, 10 GB of storage, daily backups kept 7 days and a spend limit; memory, active vCPU and bandwidth metered from the first unit, then $1 per million requests and $8 per million operations beyond the included amounts. [Prisma pricing](https://www.prisma.io/pricing), as of October 2026. - Trade-offs: no point-in-time recovery, no preview that copies production data, one region per service. We make Prisma Compute and Prisma Postgres; the entries are in alphabetical order, and we checked Neon, Railway, Render, Supabase and Vercel against their own docs on 2026-09-30. - - Compare: [Prisma Postgres vs Neon pricing](/prisma-postgres-vs-neon-pricing-2026). + - Compare: [Prisma Postgres vs Neon](https://www.prisma.io/compare/neon) and [Prisma Postgres vs Neon pricing](/prisma-postgres-vs-neon-pricing-2026). 2. **Railway** - Best for: deploying the repository you have, in any language, next to a Postgres template Railway maintains. diff --git a/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-neon.mdx b/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-neon.mdx index 246aec39551..20a17d412cc 100644 --- a/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-neon.mdx +++ b/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-neon.mdx @@ -308,7 +308,7 @@ If you want to complete both changes on the same day, deploy and verify the Pris ## Hosting the app too -If the application that uses this database still runs on another host, [Prisma Compute](/compute) can host it next to Prisma Postgres, in the same region, so the app and its database deploy together. Compute runs TypeScript apps (Node.js, Bun or Next.js) on Bun, in one region per service, and does not run WebSocket servers. An existing app gets a [Prisma Composer](/composer) declaration next to its server code, and Composer is in Early Access. For a Neon user, [Neon alternatives with app hosting and Postgres in one place](https://www.prisma.io/blog/neon-vs-prisma-postgres-app-hosting-in-one-place) compares this setup with Railway, Render and Supabase. +If the application that uses this database still runs on another host, [Prisma Compute](/compute) can host it next to Prisma Postgres, in the same region, so the app and its database deploy together. Compute runs TypeScript apps (Node.js, Bun or Next.js) on Bun, in one region per service, and does not run WebSocket servers. An existing app gets a [Prisma Composer](/composer) declaration next to its server code, and Composer is in Early Access. For a Neon user, [Prisma Postgres vs Neon](https://www.prisma.io/compare/neon) is the head-to-head, and [Neon alternatives with app hosting and Postgres in one place](https://www.prisma.io/blog/neon-vs-prisma-postgres-app-hosting-in-one-place) compares this setup with Railway, Render and Supabase. ## Other migration guides diff --git a/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-supabase.mdx b/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-supabase.mdx index 23ca04ae992..f6329e74959 100644 --- a/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-supabase.mdx +++ b/apps/docs/content/docs/guides/switch-to-prisma-postgres/from-supabase.mdx @@ -329,5 +329,6 @@ If you want to complete both changes on the same day, deploy and verify the Pris ## Other migration guides +- [Prisma Postgres vs Supabase](https://www.prisma.io/compare/supabase) - [Migrate from Neon to Prisma Postgres](/guides/switch-to-prisma-postgres/from-neon) - [Migrate another PostgreSQL database to Prisma Postgres](/prisma-postgres/import-from-existing-database-postgresql) diff --git a/apps/docs/content/docs/postgres/import-from-existing-database.mdx b/apps/docs/content/docs/postgres/import-from-existing-database.mdx index 1a72f0bfe01..61527d5c32b 100644 --- a/apps/docs/content/docs/postgres/import-from-existing-database.mdx +++ b/apps/docs/content/docs/postgres/import-from-existing-database.mdx @@ -69,7 +69,7 @@ Run the same count against the local database. The [full import guide](/prisma-p ## Moving from Neon or Supabase? -The same dump and restore works from a hosted database. The [Neon guide](/guides/switch-to-prisma-postgres/from-neon) covers selecting the branch and the unpooled endpoint, and the [Supabase guide](/guides/switch-to-prisma-postgres/from-supabase) lists what does not move with the data: Auth users, Storage objects and Edge Functions. +The same dump and restore works from a hosted database. The [Neon guide](/guides/switch-to-prisma-postgres/from-neon) covers selecting the branch and the unpooled endpoint, and the [Supabase guide](/guides/switch-to-prisma-postgres/from-supabase) lists what does not move with the data: Auth users, Storage objects and Edge Functions. PlanetScale Postgres uses the same dump and restore over a direct (non-PgBouncer) connection; [Prisma Postgres vs PlanetScale](https://www.prisma.io/compare/planetscale) is the head-to-head. The dump is a snapshot of the source at the moment `pg_dump` runs, so stop or drain writes to the source before the final dump and keep them stopped until you have verified the import and switched the application over, as the [full guide's export step](/prisma-postgres/import-from-existing-database-postgresql#3-stop-source-writes-and-export-the-final-snapshot) spells out. If you cannot pause writes for that long, this snapshot process is not the right fit: you need a separately tested process that captures and replays every write made after the snapshot. diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs index 500ba3167f1..2499952fa05 100644 --- a/apps/site/next.config.mjs +++ b/apps/site/next.config.mjs @@ -273,6 +273,9 @@ const agentMarkdownPaths = [ "/compare/convex", "/compare/kysely", "/compare/mongoose", + "/compare/neon", + "/compare/planetscale", + "/compare/supabase", ]; /** diff --git a/apps/site/public/logos/customers/bucket.png b/apps/site/public/logos/customers/bucket.png new file mode 100644 index 00000000000..7da299bc92d Binary files /dev/null and b/apps/site/public/logos/customers/bucket.png differ diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index 02a9baff2c2..027a4830791 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -11,3 +11,62 @@ export const COMPARISON_CTA = { primaryCta: { label: "Get started free", href: "https://console.prisma.io" }, secondaryCta: { label: "Talk to us", href: "/contact" }, } as const; + +/** + * Closing action on ORM comparisons (Kysely, Mongoose). Prisma ORM is free and + * open source, not a trial. The 500K+ line is how many people use it; it stays. + * The buttons go to the ORM quickstart and Prisma Postgres rather than a + * generic console pitch. Do not reuse COMPARISON_CTA.body here: that dek is + * for hosted Prisma Postgres (free plan, credit card). + */ +export const ORM_COMPARISON_CTA = { + headline: "Add Prisma ORM to an existing project", + headlineMaxWidth: "max-w-[28ch]", + body: "Prisma ORM is free and open source, and always will be.", + bodyMaxWidth: "max-w-[52ch]", + checks: [ + { label: "Add the library", color: "text-prism-cyan-500" }, + { label: "Read the ORM docs", color: "text-prism-yellow-400" }, + { label: "Trusted by 500K+ developers globally", color: "text-prism-red-500" }, + ], + primaryCta: { + label: "Get started with Prisma ORM", + href: "/docs/prisma-orm/quickstart/postgresql", + }, + secondaryCta: { + label: "Get started with Prisma Postgres", + href: "https://console.prisma.io", + }, +} as const; + +/** Temporary Prisma Postgres in the browser, no account. */ +export const CREATE_DB_WEB = "https://create-db.prisma.io/web"; + +/** Open create-db off the comparison so the reader does not lose the page. */ +export const CREATE_DB_NEW_TAB = { + target: "_blank", + rel: "noopener noreferrer", +} as const; + +export const ORM_HERO_CTAS = { + primary: { + label: "Get started with Prisma ORM", + href: "/docs/prisma-orm/quickstart/postgresql", + }, + secondary: { + label: "Create an instant database", + href: CREATE_DB_WEB, + }, +} as const; + +/** Hero buttons on Postgres/platform comparisons (Convex). */ +export const POSTGRES_HERO_CTAS = { + primary: { + label: "Get started with Prisma Postgres", + href: "https://console.prisma.io", + }, + secondary: { + label: "Create an instant database", + href: CREATE_DB_WEB, + }, +} as const; diff --git a/apps/site/src/app/compare/anchor-heading.tsx b/apps/site/src/app/compare/anchor-heading.tsx new file mode 100644 index 00000000000..e161630a237 --- /dev/null +++ b/apps/site/src/app/compare/anchor-heading.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Check, Link as LinkIcon } from "lucide-react"; +import { COPIED_VISIBLE_MS } from "./copied"; + +function headingText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(headingText).join(""); + if (node && typeof node === "object" && "props" in node) { + return headingText((node as { props: { children?: ReactNode } }).props.children); + } + return ""; +} + +/** + * A section heading with a copy-link button as a sibling, so the heading's + * accessible name stays the visible text. The button is absolutely positioned + * so it never moves the heading, and only appears on devices that can hover. + * Inside a frame or card, `placement="end"` puts it at the heading's right end + * instead, where the left gutter would fall outside the frame. + */ +export function AnchorHeading({ + level, + id, + className, + placement = "gutter", + children, +}: { + level: 2 | 3; + id: string; + className?: string; + placement?: "gutter" | "end"; + children: ReactNode; +}) { + const Tag = level === 2 ? "h2" : "h3"; + const end = placement === "end"; + const [copied, setCopied] = useState(false); + const [inTabOrder, setInTabOrder] = useState(false); + const timer = useRef>(undefined); + const label = headingText(children).trim(); + + useEffect(() => () => clearTimeout(timer.current), []); + + async function copy() { + const url = new URL(window.location.href); + url.hash = id; + window.history.replaceState(window.history.state, "", url); + try { + await navigator.clipboard.writeText(url.toString()); + } catch { + return; + } + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), COPIED_VISIBLE_MS); + } + + return ( +
setInTabOrder(true)} + onMouseLeave={() => setInTabOrder(false)} + onFocusCapture={() => setInTabOrder(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setInTabOrder(false); + } + }} + > + + {children} + + +
+ ); +} diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx new file mode 100644 index 00000000000..1f508124a54 --- /dev/null +++ b/apps/site/src/app/compare/article-toc.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { PageAiActions } from "./page-ai-actions"; + +/** A short label for one or more consecutive sections; `id` is the link target. */ +export type TocItem = { id: string; label: string; also?: readonly string[] }; + +/** Input that means the reader, not a TOC click, is moving the page. */ +const READER_INPUT = ["wheel", "touchstart", "keydown", "pointerdown"] as const; + +/** + * The item for the section being read: the last heading above the top third + * of the viewport, the first item before any heading gets there, and the last + * item once the page cannot scroll further. A clicked item stays marked until + * the reader scrolls again, so a short final section can still show the item + * that was clicked. + */ +function useActiveItem(items: readonly TocItem[]) { + const [active, setActive] = useState(items[0]?.id); + const pinned = useRef(null); + + useEffect(() => { + let frame = 0; + const owner = new Map(); + for (const item of items) { + owner.set(item.id, item.id); + for (const id of item.also ?? []) owner.set(id, item.id); + } + + const measure = () => { + frame = 0; + if (pinned.current) return; + const headings = [...owner.keys()] + .map((id) => document.getElementById(id)) + .filter((element): element is HTMLElement => element !== null) + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + if (headings.length === 0) return; + const line = Math.max(120, window.innerHeight * 0.3); + let current = headings[0].id; + for (const heading of headings) { + if (heading.getBoundingClientRect().top <= line) current = heading.id; + } + const atBottom = + window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2; + if (atBottom) current = headings[headings.length - 1].id; + setActive(owner.get(current)); + }; + + const schedule = () => { + if (!frame) frame = requestAnimationFrame(measure); + }; + const release = () => { + pinned.current = null; + }; + const followHash = () => { + const target = owner.get(decodeURIComponent(window.location.hash.slice(1))); + if (target) { + pinned.current = target; + setActive(target); + } else { + release(); + schedule(); + } + }; + + if (window.location.hash) followHash(); + else measure(); + + window.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule, { passive: true }); + window.addEventListener("popstate", followHash); + for (const type of READER_INPUT) window.addEventListener(type, release, { passive: true }); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + window.removeEventListener("popstate", followHash); + for (const type of READER_INPUT) window.removeEventListener(type, release); + }; + }, [items]); + + const choose = (id: string) => { + pinned.current = id; + setActive(id); + }; + return [active, choose] as const; +} + +function TocList({ + items, + active, + onChoose, +}: { + items: readonly TocItem[]; + active: string | undefined; + onChoose: (id: string) => void; +}) { + return ( + + ); +} + +/** + * The sticky right-hand list, matching the docs' table of contents. Copy and + * the AI marks sit above “On this page”; spacing matches the pre-compression rail. + */ +export function ArticleToc({ + items, + markdownPath, +}: { + items: readonly TocItem[]; + markdownPath?: string; +}) { + const [active, choose] = useActiveItem(items); + return ( + + ); +} + +/** The same list folded into a disclosure for narrow screens; it closes after a pick. */ +export function ArticleTocDisclosure({ + items, + markdownPath, +}: { + items: readonly TocItem[]; + markdownPath?: string; +}) { + const [active, choose] = useActiveItem(items); + const details = useRef(null); + return ( +
+ + On this page + + + +
+ ); +} diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx new file mode 100644 index 00000000000..90b9359cd88 --- /dev/null +++ b/apps/site/src/app/compare/article.tsx @@ -0,0 +1,318 @@ +import { Children, isValidElement, type ReactNode } from "react"; +import Link from "next/link"; +import { ArrowRight, ChevronRight } from "lucide-react"; +import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; +import { + COMPARISON_CTA, + CREATE_DB_NEW_TAB, + CREATE_DB_WEB, + ORM_COMPARISON_CTA, + ORM_HERO_CTAS, + POSTGRES_HERO_CTAS, +} from "./_content"; +import { ArticleToc, ArticleTocDisclosure, type TocItem } from "./article-toc"; +import { COMPARISONS, type ComparisonSlug } from "./comparisons"; +import { CompareTable } from "./compare-table"; +import { QUIET_LINK } from "./table-styles"; +import { renderFaqAnswer } from "@/lib/faq-answer"; + +const PROSE = + "answer-prose prose max-w-none text-foreground selection:bg-prism-cyan-100 prose-headings:scroll-mt-28 prose-headings:font-heading prose-headings:font-medium prose-headings:text-balance prose-h2:mt-14 prose-h2:text-2xl prose-h3:mt-8 prose-h3:text-xl prose-p:text-pretty prose-p:leading-[1.8] prose-a:text-foreground prose-a:underline prose-a:underline-offset-4 prose-a:hover:text-prism-cyan-700 prose-strong:font-semibold prose-li:leading-[1.8] prose-code:break-words prose-th:text-left [&_:focus-visible]:outline-2 [&_:focus-visible]:outline-offset-2 [&_:focus-visible]:outline-foreground/70"; + +const SECTION_H2 = "font-heading text-2xl font-medium"; + +/** + * Short labels for the heading list, set apart from the headings themselves. + * An item can stand for several consecutive sections; it stays marked while + * any of them is being read. + */ +const AI_MARKDOWN: Partial> = { + kysely: "/compare/kysely.md", + mongoose: "/compare/mongoose.md", + convex: "/compare/convex.md", + neon: "/compare/neon.md", + planetscale: "/compare/planetscale.md", + supabase: "/compare/supabase.md", +}; + +const TOC: Record = { + kysely: [ + { id: "choose", label: "When to choose" }, + { id: "same-query", label: "Same query" }, + { id: "feature-table", label: "Features" }, + { id: "databases", label: "Databases" }, + { id: "apart", label: "Why Prisma ORM" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "same-job", label: "Each task" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], + mongoose: [ + { id: "choose", label: "When to choose" }, + { id: "same-query", label: "Same query" }, + { id: "feature-table", label: "Features" }, + { id: "databases", label: "Databases" }, + { id: "apart", label: "Why Prisma ORM" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "same-job", label: "Each task" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], + convex: [ + { id: "choose", label: "When to choose" }, + { id: "feature-table", label: "Features" }, + { id: "where-each", label: "Where each fits" }, + { id: "apart", label: "Why Prisma Postgres" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], + neon: [ + { id: "choose", label: "When to choose" }, + { id: "feature-table", label: "Features" }, + { id: "where-each", label: "Where each fits" }, + { id: "apart", label: "Why Prisma Postgres" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], + planetscale: [ + { id: "choose", label: "When to choose" }, + { id: "feature-table", label: "Features" }, + { id: "where-each", label: "Where each fits" }, + { id: "apart", label: "Why Prisma Postgres" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], + supabase: [ + { id: "choose", label: "When to choose" }, + { id: "feature-table", label: "Features" }, + { id: "where-each", label: "Where each fits" }, + { id: "apart", label: "Why Prisma Postgres" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When it is the wrong choice" }, + { id: "migrate", label: "Switching", also: ["migrate-back"] }, + { id: "proof", label: "What teams say" }, + { id: "faq", label: "FAQ", also: ["next"] }, + { id: "sources", label: "Sources" }, + ], +}; + +/** + * Who-makes-this line under the byline. Full width of the hero card so it + * reads as one line on desktop; wraps at 390 without overflowing. + */ +export function comparisonDisclosure({ + competitor, + checked, + prismaProduct = "Prisma ORM", +}: { + competitor: string; + checked: string; + prismaProduct?: string; +}) { + return ( +

+ Prisma makes {prismaProduct}. {competitor} figures come from the pages listed under{" "} + + Sources + + , checked {checked}. +

+ ); +} + +/** + * First body section: one row per choose contrast, competitor | Prisma. + * Same CompareTable chrome and stacked-card phone layout as Features. + */ +export function comparisonChooseTable({ + competitor, + prisma, + competitorItems, + prismaItems, + labelledBy = "choose", +}: { + competitor: string; + prisma: string; + competitorItems: readonly string[]; + prismaItems: readonly string[]; + labelledBy?: string; +}) { + return ( + ({ + cells: [renderFaqAnswer(item), renderFaqAnswer(prismaItems[index] ?? "")], + }))} + /> + ); +} + +/** Buttons under the introduction. ORM pages lead with the ORM quickstart. */ +export function comparisonHeroCtas(kind: "orm" | "postgres" = "postgres") { + const primary = kind === "orm" ? ORM_HERO_CTAS.primary : POSTGRES_HERO_CTAS.primary; + const secondary = kind === "orm" ? ORM_HERO_CTAS.secondary : POSTGRES_HERO_CTAS.secondary; + return ( +

+ + {primary.label} + {" "} + + {secondary.label} + +

+ ); +} + +/** Closing action. ORM pages keep the 500K+ Prisma ORM figure and ORM-specific buttons. */ +export function comparisonClosingCta(kind: "orm" | "postgres" = "postgres") { + const cta = kind === "orm" ? ORM_COMPARISON_CTA : COMPARISON_CTA; + return ( +
+

+ {cta.headline} +

+

{cta.body}

+ {kind === "postgres" ? ( +

+ {ORM_COMPARISON_CTA.body} +

+ ) : null} +
    + {cta.checks.map((item) => ( +
  • {item.label}
  • + ))} +
+

+ Need a database?{" "} + + Create an instant database + + . +

+

+ + {cta.primaryCta.label} + {" "} + + {cta.secondaryCta.label} + +

+
+ ); +} + +function articleParts(children: ReactNode) { + const parts = Children.toArray(children); + if ((parts.length !== 3 && parts.length !== 4) || parts.some((part) => !isValidElement(part))) { + throw new Error("A comparison article needs an introduction, a body, and a closing action."); + } + return parts; +} + +/** + * The same article frame as the product guides: a rounded muted hero card + * at the Codex guide width (`max-w-site` + `px-6 sm:px-8`), heading list, + * 760px reading column, closing action, and related links. Hero type matches + * that guide: `max-w-4xl` title, `max-w-3xl` dek. TOC stays on the right. + * The column + rail center as one unit, like `/blog/serverless-postgres`. + * The directory stays separate. + */ +export function CompareArticle({ slug, children }: { slug: ComparisonSlug; children: ReactNode }) { + const entry = COMPARISONS.find((item) => item.slug === slug); + if (!entry) throw new Error(`Unknown comparison: ${slug}`); + const [intro, body, closing, footnote] = articleParts(children); + const related = COMPARISONS.filter((item) => item.slug !== slug); + + return ( +
+
+
+ + {intro} +
+
+ +
+
+
+
+ +
+
{body}
+ {closing} + {footnote} + +
+ +
+
+
+ ); +} diff --git a/apps/site/src/app/compare/byline.tsx b/apps/site/src/app/compare/byline.tsx new file mode 100644 index 00000000000..96fc1c43266 --- /dev/null +++ b/apps/site/src/app/compare/byline.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export type ComparisonAuthor = { + name: string; + path: string; + image: string; +}; + +export type ComparisonDate = { + iso: string; + label: string; +}; + +/** + * Martin's profiles from the blog E-E-A-T author bios + * (`apps/blog/src/lib/author-bios.ts`). GitHub was checked live: the + * `vanrensbird` account is named Martin Janse van Rensburg and lists Prisma. + * LinkedIn and X are the same URLs the team maintains on that bio. Do not add + * other authors' URLs here unless they are confirmed the same way. + */ +export const MARTIN_SOCIALS = [ + { label: "X", href: "https://x.com/vanrensbird" }, + { label: "LinkedIn", href: "https://www.linkedin.com/in/martjvr/" }, + { label: "GitHub", href: "https://github.com/vanrensbird" }, +] as const; + +const ICON = + "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground/80 transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70"; + +function XMark() { + return ( + + + + ); +} + +function LinkedInMark() { + return ( + + + + ); +} + +function GithubMark() { + return ( + + + + ); +} + +function socialIcon(label: (typeof MARTIN_SOCIALS)[number]["label"]): ReactNode { + if (label === "LinkedIn") return ; + if (label === "GitHub") return ; + return ; +} + +/** Confirmed profile URLs for Article JSON-LD `author.sameAs`. */ +export function authorSameAs() { + return MARTIN_SOCIALS.map((social) => social.href); +} + +/** Person node for Article JSON-LD. `sameAs` is only the confirmed profiles. */ +export function comparisonAuthorPerson(author: ComparisonAuthor, baseUrl: string) { + return { + "@type": "Person" as const, + name: author.name, + url: new URL(author.path, baseUrl).toString(), + sameAs: authorSameAs(), + }; +} + +/** Byline with reading time and Martin's profile icons. Called as a function so Markdown sees the tree. */ +export function comparisonByline({ + author, + updated, + minutes, +}: { + author: ComparisonAuthor; + updated: ComparisonDate; + minutes: number; +}) { + return ( +

+ + + + By{" "} + + {author.name} + + {" "} + ·{" "} + + Updated + · + {minutes} min read + + + + + {MARTIN_SOCIALS.map((social) => ( + + + + {socialIcon(social.label)} + {social.label} + + + + {social.label} + + + ))} + + +

+ ); +} diff --git a/apps/site/src/app/compare/compare-table.tsx b/apps/site/src/app/compare/compare-table.tsx new file mode 100644 index 00000000000..c8a5f5a70be --- /dev/null +++ b/apps/site/src/app/compare/compare-table.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { + SCROLL_HINT, + STICKY, + TABLE_FRAME, + TABLE_ROW, + TABLE_STACK, + TD, + TH_COL, + TH_ROW, +} from "./table-styles"; +import { TableScrollRegion } from "./table-scroll-region"; + +export type CompareTableRow = { + /** Omit on choose tables: those are competitor | Prisma columns only. */ + label?: string; + cells: readonly ReactNode[]; +}; + +export type CompareTableProps = { + /** Id of the section heading that names the table for screen readers. */ + labelledBy: string; + /** Header over the row labels. Omit with unlabelled choose rows. */ + corner?: string; + columns: readonly string[]; + rows: readonly CompareTableRow[]; + minWidth: string; +}; + +/** + * Feature, database, task, and choose matrices. Below 640px each row stacks as + * a card with `data-label` on every cell. The Markdown serializer reads these + * props directly, so it never renders this component. + */ +export function CompareTable({ labelledBy, corner, columns, rows, minWidth }: CompareTableProps) { + const labelled = Boolean(corner); + const columnWidth = `${(labelled ? 78 : 100) / columns.length}%`; + return ( + + + + + {labelled ? ( + + ) : null} + {columns.map((column) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {labelled ? ( + + ) : null} + {row.cells.map((cell, index) => ( + + ))} + + ))} + +
+ {corner} + + {column} +
+ {row.label} + + {cell} +
+
+ ); +} diff --git a/apps/site/src/app/compare/comparisons.ts b/apps/site/src/app/compare/comparisons.ts index 35c3e0f77fa..331b8d1bfb3 100644 --- a/apps/site/src/app/compare/comparisons.ts +++ b/apps/site/src/app/compare/comparisons.ts @@ -1,6 +1,9 @@ import { CONVEX_DESCRIPTION, CONVEX_TITLE } from "./convex/_content"; import { KYSELY_DESCRIPTION, KYSELY_TITLE } from "./kysely/_content"; import { MONGOOSE_DESCRIPTION, MONGOOSE_TITLE } from "./mongoose/_content"; +import { NEON_DESCRIPTION, NEON_TITLE } from "./neon/_content"; +import { PLANETSCALE_DESCRIPTION, PLANETSCALE_TITLE } from "./planetscale/_content"; +import { SUPABASE_DESCRIPTION, SUPABASE_TITLE } from "./supabase/_content"; export const COMPARISON_INDEX = { path: "/compare", @@ -60,6 +63,27 @@ export const COMPARISONS = [ title: MONGOOSE_TITLE, description: MONGOOSE_DESCRIPTION, }, + { + slug: "neon", + category: "postgres", + competitor: "Neon", + title: NEON_TITLE, + description: NEON_DESCRIPTION, + }, + { + slug: "planetscale", + category: "postgres", + competitor: "PlanetScale", + title: PLANETSCALE_TITLE, + description: PLANETSCALE_DESCRIPTION, + }, + { + slug: "supabase", + category: "postgres", + competitor: "Supabase", + title: SUPABASE_TITLE, + description: SUPABASE_DESCRIPTION, + }, ] as const satisfies readonly ComparisonEntry[]; export type ComparisonSlug = (typeof COMPARISONS)[number]["slug"]; diff --git a/apps/site/src/app/compare/convex.json/route.ts b/apps/site/src/app/compare/convex.json/route.ts new file mode 100644 index 00000000000..27bfdbc2a00 --- /dev/null +++ b/apps/site/src/app/compare/convex.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getConvexFacts } from "../convex/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getConvexFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/convex/_content.ts b/apps/site/src/app/compare/convex/_content.ts index 2b9892a5b8c..67f6b3bebe6 100644 --- a/apps/site/src/app/compare/convex/_content.ts +++ b/apps/site/src/app/compare/convex/_content.ts @@ -3,10 +3,20 @@ export const CONVEX_PATH = "/compare/convex"; export const CONVEX_TITLE = "Prisma Postgres vs Convex: standard Postgres or a reactive database?"; export const CONVEX_DESCRIPTION = - "Convex is a reactive database with TypeScript query functions. Prisma Postgres is managed PostgreSQL, and Prisma ORM is optional. Which fits your app, with sourced figures."; + "Convex is a reactive database with TypeScript query functions. Prisma Postgres is managed PostgreSQL, and Prisma ORM is optional. Checked October 2026."; export const CHECKED = "October 2026"; +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const CONVEX_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const CONVEX_PUBLISHED = { iso: "2026-10-16", label: "October 2026" } as const; +export const CONVEX_UPDATED = { iso: "2026-10-18", label: "October 2026" } as const; + export const CHOOSE_PRISMA = [ "You want a standard SQL database. Prisma Postgres is PostgreSQL.", "You want to query it with SQL, or with Prisma ORM. Prisma ORM is optional.", @@ -25,6 +35,7 @@ export type CompareRow = { feature: string; convex: string; prisma: string; + sources: { convex: string; prisma: string }; }; /** Columns run Feature, Convex, Prisma Postgres: alphabetical by product. */ @@ -32,9 +43,13 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ { feature: "Database", convex: - "The Convex database. Application queries are TypeScript. Convex cloud stores data in MySQL. The open-source version can use SQLite, Postgres, or MySQL as the persistence layer.", + "The Convex database. Application queries are TypeScript. Convex cloud stores data in MySQL on PlanetScale. The open-source version can use SQLite, Postgres, or MySQL as the persistence layer.", prisma: "[Prisma Postgres](/docs/postgres) is PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional.", + sources: { + convex: "https://docs.convex.dev/understanding/", + prisma: "https://www.prisma.io/docs/postgres", + }, }, { feature: "Queries", @@ -42,6 +57,10 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "TypeScript query and mutation functions. The overview says there is no SQL to write and no ORM is required. A mutation runs as a transaction.", prisma: "SQL against PostgreSQL, or [Prisma ORM](/docs/orm). Prisma ORM 8 is a release candidate. Models live in contract.prisma.", + sources: { + convex: "https://docs.convex.dev/understanding/", + prisma: "https://www.prisma.io/docs/orm", + }, }, { feature: "Realtime updates", @@ -49,6 +68,10 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "A query reruns when the data it read changes. The client library receives the new result over a WebSocket.", prisma: "No built-in reactive query sync. [Prisma Compute](/docs/compute) does not currently support WebSocket servers.", + sources: { + convex: "https://docs.convex.dev/understanding/", + prisma: "https://www.prisma.io/docs/compute/limitations", + }, }, { feature: "App hosting", @@ -56,12 +79,20 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "Convex runs the database and the server functions. The hosting guide says to publish the frontend with a provider such as Vercel or Netlify.", prisma: "Prisma Compute hosts the TypeScript app as HTTP services. It is separate from Prisma Postgres. Compute has no built-in cron scheduler. Scheduled jobs can come from Composer's cron building block.", + sources: { + convex: "https://docs.convex.dev/production/hosting", + prisma: "https://www.prisma.io/docs/compute/limitations", + }, }, { feature: "Developer fees", convex: "Professional is $25 per developer per month, for 1 to 20 developers. Free and Starter list 1 to 6 developers. Business and Enterprise list 50 or more developers and a $2,500 monthly minimum.", prisma: "The pricing page does not list a per-developer fee.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, }, { feature: "Free plan", @@ -69,6 +100,10 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "Free, or $0 a month and pay as you go. The resource table includes 0.5 GB of database storage and 1 million function calls, then usage rates.", prisma: "Prisma Postgres Free is $0, with 200,000 operations a month, 2 GB of storage, and 50 databases. Prisma Compute Free includes 1 million requests a month. The Free plan says no credit card is required.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, }, ]; @@ -88,23 +123,71 @@ export const PRISMA_FITS = [ export const APART = [ { + id: "postgresql", title: "PostgreSQL", body: "Prisma Postgres is PostgreSQL. You connect with a Postgres client. Prisma ORM is optional on that connection.", }, { + id: "optional-orm", title: "A contract-based ORM, when you want one", body: "Prisma ORM 8 is a release candidate. Models live in contract.prisma, and contract emit writes the types. You can skip it and use SQL.", }, { + id: "http-hosting", title: "HTTP app hosting", body: "Prisma Compute hosts the TypeScript app. The current release focuses on HTTP services. WebSocket servers are not supported. Compute has no built-in cron scheduler. Scheduled jobs can come from Composer's cron building block, which adds a scheduler service.", }, { + id: "no-seat-fee", title: "Pricing without a per-developer fee", body: "The pricing page lists Prisma Postgres by operations, storage, and databases, and Prisma Compute by requests, memory, CPU, and bandwidth. It does not list a per-developer fee. Starter, Pro, and Business list spend limits.", }, ]; +export const PRICING: readonly { + label: string; + convex: string; + prisma: string; + sources: { convex: string; prisma: string }; +}[] = [ + { + label: "Free plan", + convex: "Free, or $0 a month and pay as you go. 1 to 6 developers.", + prisma: "Prisma Postgres Free is $0. No credit card required.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Paid plans", + convex: "Professional is $25 per developer per month. Business and Enterprise have a $2,500 monthly minimum.", + prisma: "Starter $10, Pro $49, Business $129 a month for Prisma Postgres and Prisma Compute.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Developer seats", + convex: "A line on Professional, Business, and Enterprise.", + prisma: "None. The pricing page does not list a per-developer fee.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Database storage on free", + convex: "0.5 GB included, then $0.22 per additional GB.", + prisma: "2 GB included, with 200,000 operations and 50 databases.", + sources: { + convex: "https://www.convex.dev/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + export const WRONG_CHOICE = [ "You want query results pushed to the browser when data changes. Convex subscriptions use WebSockets. Prisma Postgres has no built-in reactive query sync, and Prisma Compute does not currently support WebSocket servers. See [Compute limitations](/docs/compute/limitations).", "You want the database, server functions, file storage, and auth in one Convex deployment, and the application should not write SQL. The [Convex overview](https://docs.convex.dev/understanding/) describes that model.", @@ -114,7 +197,7 @@ export const FAQ: readonly { question: string; answer: string }[] = [ { question: "Is Convex PostgreSQL?", answer: - "The application does not write SQL. Convex cloud stores data in MySQL. The open-source version can use SQLite, Postgres, or MySQL as the persistence layer, and queries stay TypeScript functions. Source: [Convex overview](https://docs.convex.dev/understanding/), October 2026.", + "The application does not write SQL. Convex cloud stores data in MySQL on PlanetScale. The open-source version can use SQLite, Postgres, or MySQL as the persistence layer, and queries stay TypeScript functions. Source: [Convex overview](https://docs.convex.dev/understanding/), October 2026.", }, { question: "Does Convex charge per developer?", @@ -132,3 +215,22 @@ export const FAQ: readonly { question: string; answer: string }[] = [ "Prisma Postgres has no built-in reactive query sync. Convex reruns a query when its data changes and sends the result over a WebSocket. Prisma Compute does not currently support WebSocket servers. Source: [Convex overview](https://docs.convex.dev/understanding/) and [Compute limitations](/docs/compute/limitations), October 2026.", }, ]; + +export const TOOLS = { + convex: { + name: "Convex", + softwareVersion: "1.47.0", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/get-convex/convex-backend", + programmingLanguage: "TypeScript", + url: "https://www.convex.dev", + }, + prisma: { + name: "Prisma Postgres", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/postgres", + }, +} as const; diff --git a/apps/site/src/app/compare/convex/_sections.tsx b/apps/site/src/app/compare/convex/_sections.tsx index b8bfc40a9d9..88855de0b1d 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -5,12 +5,21 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { CheckBold } from "@/components/icons/forma"; -import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; -import { RoleKicker } from "@/components/brand/role-kicker"; -import { Texture } from "@/components/brand/texture"; -import { CtaBurst } from "@/components/sections/cta-burst"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK } from "../table-styles"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -18,224 +27,164 @@ import { CHOOSE_CONVEX, CHOOSE_PRISMA, COMPARE_ROWS, + CONVEX_AUTHOR, CONVEX_FITS, CONVEX_TITLE, + CONVEX_UPDATED, FAQ, + PRICING, PRISMA_FITS, WRONG_CHOICE, } from "./_content"; const INLINE = - "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-black/60"; + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; +const CHOOSE_LABEL = "font-heading text-lg leading-snug font-medium"; const PROOF = TESTIMONIALS.filter( - (item) => item.name === "Martin Adams" || item.name === "Ron Cohen", + (item) => item.name === "Ron Cohen" || item.name === "Omar López", ); -/** Build the shared content tree for the HTML page and its Markdown rendition. */ -export function getConvexPageContent() { - return ( - <> -
-
-
-
-
-
- -
-
- Compare -

- {CONVEX_TITLE} -

-

- Pick{" "} - - Prisma Postgres - {" "} - when you want a standard SQL database. It is PostgreSQL,{" "} - - Prisma ORM - {" "} - is optional, and{" "} - - Prisma Compute - {" "} - hosts the app. Pick{" "} - - Convex - {" "} - when you want a reactive database and TypeScript query functions, with the frontend - on a host such as Vercel or Netlify. Prisma makes Prisma Postgres, Prisma ORM, and - Prisma Compute. All{" "} - - Prisma comparisons - {" "} - are listed in one place. -

-

- Every competitor figure on this page has a source, listed under the table and - checked {CHECKED}. -

-
- - Read the Postgres docs - - - See which fits - -
-
-
-
-
+const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; -
-
-
-

- Choose Prisma Postgres if -

-
    - {CHOOSE_PRISMA.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
-

- Choose Convex if -

-
    - {CHOOSE_CONVEX.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
+const SOURCES = [ + { + product: "Convex", + links: [ + { label: "Convex overview", href: "https://docs.convex.dev/understanding/" }, + { label: "convex on npm", href: "https://www.npmjs.com/package/convex" }, + { label: "Frontend hosting", href: "https://docs.convex.dev/production/hosting" }, + { label: "Backup and export", href: "https://docs.convex.dev/database/backup-restore" }, + { label: "Convex pricing", href: "https://www.convex.dev/pricing" }, + { + label: "Apache License 2.0", + href: "https://github.com/get-convex/convex-backend/blob/main/LICENSE", + }, + ], + }, + { + product: "Prisma Postgres, Prisma ORM, and Prisma Compute", + links: [ + { label: "Prisma Postgres", href: "/docs/postgres" }, + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Supported databases", href: "/docs/orm/supported-databases" }, + { label: "Prisma Compute", href: "/docs/compute" }, + { label: "Compute limitations", href: "/docs/compute/limitations" }, + { label: "Composer building blocks", href: "/docs/composer/building-blocks" }, + { + label: "existing-database guide", + href: "/docs/prisma-orm/add-to-existing-project/postgresql", + }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +function buildConvexPage(minutes: number) { + return [ +
+

+ {CONVEX_TITLE} +

+

+ Pick{" "} + + Prisma Postgres + {" "} + when you want a standard SQL database. It is PostgreSQL,{" "} + + Prisma ORM + {" "} + is optional, and{" "} + + Prisma Compute + {" "} + hosts the TypeScript app as HTTP services. Pick{" "} + + Convex + {" "} + when you want a reactive database and TypeScript query functions, with the frontend on a + host such as Vercel or Netlify. +

+ {comparisonHeroCtas("postgres")} + {comparisonByline({ author: CONVEX_AUTHOR, updated: CONVEX_UPDATED, minutes })} + {comparisonDisclosure({ + competitor: "Convex", + checked: CHECKED, + prismaProduct: "Prisma Postgres", + })} +
, +
+
+ + When should I choose Convex or Prisma Postgres? + + {comparisonChooseTable({ + competitor: "Convex", + prisma: "Prisma Postgres", + competitorItems: CHOOSE_CONVEX, + prismaItems: CHOOSE_PRISMA, + })}
-
-
-

- What each one supports -

-

- Columns are alphabetical: Convex, then Prisma Postgres. Convex is a reactive database - whose cloud offering stores data in MySQL. Prisma Postgres is managed PostgreSQL, with - an optional ORM and separate app hosting. -

-
-
- - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
- Feature - - Convex - - Prisma Postgres -
- {row.feature} - - {row.convex} - - {renderFaqAnswer(row.prisma)} -
-
-
-

- Convex cells: the{" "} - - Convex overview - - ,{" "} - - frontend hosting - - , and{" "} - - pricing - - , {CHECKED}. Prisma cells:{" "} - - Prisma Postgres - - ,{" "} - - Prisma ORM - - ,{" "} - - Prisma Compute - - ,{" "} - - Compute limitations - - ,{" "} - - Composer building blocks - - , and the{" "} - - pricing page - - , {CHECKED}. -

-
+
+ + How do Convex and Prisma Postgres compare? + +

+ Convex is a reactive database. Application queries are TypeScript functions. Convex cloud + stores data in MySQL on PlanetScale. Prisma Postgres is managed PostgreSQL, with an + optional ORM and separate app hosting. Columns are in alphabetical order. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.convex), renderFaqAnswer(row.prisma)], + }))} + />
-
-
-
-

- Where Convex works well -

-

+

+ + Where does each one fit? + +
-
-
-

- What sets Prisma Postgres apart -

-
- {APART.map((item) => ( -
-

{item.title}

-

- {item.body} -

-
- ))} -
+
+ + {"What does Prisma Postgres do that Convex doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

+
+ ))}
-
-
-

- Pricing -

-
- -
-

Prisma Postgres and Prisma Compute

-

- Prisma Postgres Free is $0, with 200,000 operations a month, 2 GB of storage, and 50 - databases. Starter is $10 a month. Prisma Compute is separate app hosting. The page - does not list a per-developer fee ( - - pricing page - - , {CHECKED}). Prisma ORM is optional with Prisma Postgres, and it is free under the - Apache License 2.0. -

-
-
-
+
+ + How much do Convex and Prisma Postgres cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.convex), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. The{" "} + + pricing page + {" "} + lists the paid plans. Convex Free includes 0.5 GB of database storage and 1 million + function calls ( + + Convex pricing + + , {CHECKED}). + + } + />
-
-
-

- When Prisma Postgres is the wrong choice -

-
    - {WRONG_CHOICE.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
-
+
+ + When is Prisma Postgres the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
-
-
-
-

- Move from Convex to Prisma Postgres -

-
    -
  1. - Create the PostgreSQL database. Prisma ORM is optional. When the tables already - exist, the{" "} - - existing-database guide - {" "} - shows contract infer. -
  2. -
  3. - Export the data. Every Convex plan can download a snapshot export, a ZIP with one{" "} - documents.jsonl file per table, from the - dashboard or with npx convex export, as the{" "} - - backup and export guide - {" "} - describes. Professional adds streaming data exports. Load the JSONL into PostgreSQL - yourself. This page does not include a converter. -
  4. -
  5. - Point the app at the Postgres connection string. Host the HTTP app on Prisma - Compute, or keep the frontend host you already use. -
  6. -
-
-
-

- Move from Prisma Postgres to Convex -

-
    -
  1. - Define Convex tables and write query and mutation functions. The{" "} - - overview - {" "} - shows ctx.db.query and{" "} - ctx.db.patch. -
  2. -
  3. - Subscribe from the client with useQuery. - The client library uses a WebSocket. -
  4. -
  5. - Publish the frontend with a provider such as Vercel or Netlify, as the{" "} - - hosting guide - {" "} - describes. Prisma Postgres stays in place until you stop using it. -
  6. -
-
-
+
+ + How do I switch from Convex to Prisma Postgres? + +
    +
  1. + Create the PostgreSQL database. Prisma ORM is optional. When the tables already exist, + the{" "} + + existing-database guide + {" "} + shows contract infer. +
  2. +
  3. + Export the data. Every Convex plan can download a snapshot export, a ZIP with one{" "} + documents.jsonl file per table, from the dashboard or with{" "} + npx convex export, as the{" "} + + backup and export guide + {" "} + describes. Professional adds streaming data exports. Load the JSONL into PostgreSQL + yourself. This page does not include a converter. +
  4. +
  5. + Point the app at the Postgres connection string. Host the HTTP app on Prisma Compute, or + keep the frontend host you already use. +
  6. +
-
-
-

- What teams on Prisma say -

-

- These two quotes are on prisma.io today. One is about Prisma Postgres, and one is about - type safety in Prisma ORM. -

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-
+
+ + How do I switch from Prisma Postgres to Convex? + +
    +
  1. + Define Convex tables and write query and mutation functions. The{" "} + + overview + {" "} + shows ctx.db.query and ctx.db.patch. +
  2. +
  3. + Subscribe from the client with useQuery. The client library uses a + WebSocket. +
  4. +
  5. + Publish the frontend with a provider such as Vercel or Netlify, as the{" "} + + hosting guide + {" "} + describes. Prisma Postgres stays in place until you stop using it. +
  6. +
-
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - +
+ + What do teams say about Prisma Postgres? + +

+ Two teams on what Prisma ORM and Prisma Postgres do for them. +

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )} +
+ +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("postgres"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • ))} - +
-
-
+ ))} +
+
, + ]; +} - - - ); +const CONVEX_READING = articleReadingStats(buildConvexPage(1)); + +export function getConvexReadingStats() { + return articleReadingStats(buildConvexPage(CONVEX_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getConvexPageContent() { + return buildConvexPage(CONVEX_READING.minutes); } -/** Render the comparison using its shared content tree. */ +/** Render the comparison in the guide article frame. */ export function ConvexPage() { - return getConvexPageContent(); + return {getConvexPageContent()}; } diff --git a/apps/site/src/app/compare/convex/facts.ts b/apps/site/src/app/compare/convex/facts.ts new file mode 100644 index 00000000000..435b47686d5 --- /dev/null +++ b/apps/site/src/app/compare/convex/facts.ts @@ -0,0 +1,86 @@ +import { + COMPARE_ROWS, + CONVEX_PATH, + CONVEX_PUBLISHED, + CONVEX_UPDATED, + PRICING, + TOOLS, +} from "./_content"; +import { getConvexReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +/** Machine-readable feature and pricing cells with a source on every value. */ +export function getConvexFacts() { + const dateChecked = CONVEX_UPDATED.iso; + return { + path: CONVEX_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + convex: { + value: row.convex, + source: row.sources.convex, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + convex: { + value: row.convex, + source: row.sources.convex, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getConvexAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.convex.name, + softwareVersion: TOOLS.convex.softwareVersion, + license: TOOLS.convex.license, + codeRepository: TOOLS.convex.codeRepository, + programmingLanguage: TOOLS.convex.programmingLanguage, + url: TOOLS.convex.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getConvexArticleFields() { + const { wordCount, timeRequired } = getConvexReadingStats(); + return { + datePublished: CONVEX_PUBLISHED.iso, + dateModified: CONVEX_UPDATED.iso, + wordCount, + timeRequired, + about: getConvexAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/convex/page.tsx b/apps/site/src/app/compare/convex/page.tsx index f74bf86ce01..bed00279a72 100644 --- a/apps/site/src/app/compare/convex/page.tsx +++ b/apps/site/src/app/compare/convex/page.tsx @@ -1,20 +1,59 @@ +import localFont from "next/font/local"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; -import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; -import { CONVEX_PATH, CONVEX_TITLE, FAQ } from "./_content"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { CONVEX_AUTHOR, CONVEX_PATH, CONVEX_TITLE, FAQ } from "./_content"; +import { getConvexArticleFields } from "./facts"; import { ConvexPage } from "./_sections"; +/** + * The docs' code face. Shared with the Kysely comparison so both pages load + * one copy of the unbracketed Mona Sans Mono file. + */ +const monaSansMono = localFont({ + src: "../kysely/mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + export const metadata = getComparisonMetadata("convex"); const faqStructuredData = createFaqStructuredData(CONVEX_PATH, [...FAQ], CONVEX_TITLE); +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("convex"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getConvexArticleFields(), + image: getComparisonOgImageUrl("convex"), + author: comparisonAuthorPerson(CONVEX_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + /** Render the comparison and its article, breadcrumb and FAQ structured data. */ export default function Page() { return ( <> - + - +
+ +
); } diff --git a/apps/site/src/app/compare/copied.ts b/apps/site/src/app/compare/copied.ts new file mode 100644 index 00000000000..e60c2e7274b --- /dev/null +++ b/apps/site/src/app/compare/copied.ts @@ -0,0 +1,2 @@ +/** How long the visible “Copied” state stays up. Short flashes are easy to miss. */ +export const COPIED_VISIBLE_MS = 3200; diff --git a/apps/site/src/app/compare/kysely.json/route.ts b/apps/site/src/app/compare/kysely.json/route.ts new file mode 100644 index 00000000000..4c302f0f34c --- /dev/null +++ b/apps/site/src/app/compare/kysely.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getKyselyFacts } from "../kysely/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getKyselyFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/kysely/_content.ts b/apps/site/src/app/compare/kysely/_content.ts index a170beb5764..ed15944b050 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -1,30 +1,106 @@ export const KYSELY_PATH = "/compare/kysely"; -export const KYSELY_TITLE = "Prisma ORM vs Kysely: contract-based ORM or SQL-first query builder?"; +export const KYSELY_TITLE = "Prisma ORM vs Kysely: contract file or SQL query builder?"; export const KYSELY_DESCRIPTION = - "Prisma ORM 8 drives typed queries and migrations from one contract file. Kysely is a type-safe SQL query builder with no schema file. Which fits your project, with sourced figures."; + "Prisma ORM 8 drives typed queries and migrations from a contract file. Kysely is a type-safe SQL query builder without a schema file. Checked October 2026."; export const CHECKED = "October 2026"; +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const KYSELY_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const KYSELY_PUBLISHED = { iso: "2026-10-16", label: "October 2026" } as const; +export const KYSELY_UPDATED = { iso: "2026-10-18", label: "October 2026" } as const; + export const CHOOSE_PRISMA = [ - "You want models and relations in a contract file, contract.prisma.", + "You want models and relations in a contract file, `contract.prisma`.", "You want typed model queries and a SQL query builder from the same database client.", - "You want migration plan to write a TypeScript migration from that contract. On PostgreSQL, migration show prints the SQL.", - "You want contract infer to write the contract from PostgreSQL tables you already have.", + "You want `migration plan` to write a TypeScript migration from that contract. `db sign` records that the database already matches the contract.", + "You want related records as part of the model API, as in `db.orm.public.User.include`.", ]; export const CHOOSE_KYSELY = [ - "You want the library itself to be the SQL query builder, with no contract file.", + "You want the library itself to be the SQL query builder, with no contract file — a contract is the `contract.prisma` file that describes your models.", "You want table types as TypeScript interfaces, written by hand or produced by a generator.", - "You want up and down migrations as TypeScript functions you write. An optional CLI lives in kysely-ctl.", - "You need a built-in dialect for MySQL, Microsoft SQL Server, or PGlite, which Prisma ORM 8 does not ship.", + "You want up and down migrations as TypeScript functions you write. An optional CLI lives in `kysely-ctl`.", + "You need a built-in dialect — Kysely's adapter for one database — for MySQL, Microsoft SQL Server, or PGlite, which Prisma ORM 8 does not ship.", +]; + +/** One user and their posts, read from the same PostgreSQL `User` and `Post` tables. */ +export const SAME_QUERY = { + kysely: `import { jsonArrayFrom } from "kysely/helpers/postgres"; +import { db } from "./db"; // your Kysely instance + +const user = await db + .selectFrom("User") + .selectAll("User") + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("Post") + .selectAll("Post") + .whereRef("Post.authorId", "=", "User.id"), + ).as("posts"), + ]) + .where("User.email", "=", "alice@example.com") + .executeTakeFirst();`, + prisma: `import { db } from "./src/prisma/db"; + +const user = await db.orm.public.User.include("posts") + .where({ email: "alice@example.com" }) + .first();`, + /** + * Representative SQL both snippets send for that query. Labelled because it + * was not captured from a live run against one database. + */ + sql: `-- Representative SQL matching both snippets above, not captured from a live run. + +-- Kysely: jsonArrayFrom nested subquery +select + "User".*, + ( + select coalesce(json_agg(agg), '[]') + from ( + select "Post".* + from "Post" + where "Post"."authorId" = "User"."id" + ) as agg + ) as "posts" +from "User" +where "User"."email" = $1 +limit 1 + +-- Prisma ORM: include loads posts in a follow-up query +select "id", "email", "name" from "User" where "email" = $1 limit $2 +select "id", "title", "authorId" from "Post" where "authorId" in ($1)`, +} as const; + +/** Shared hover groups across the three same-query tabs (0-based Shiki line indexes). */ +export const SAME_QUERY_LINE_GROUPS: readonly { + id: string; + kysely: readonly number[]; + prisma: readonly number[]; + sql: readonly number[]; +}[] = [ + { + id: "posts", + kysely: [6, 7, 8, 9, 10, 11, 12], + prisma: [2], + sql: [5, 6, 7, 8, 9, 10, 11, 12, 19], + }, + { id: "email", kysely: [14], prisma: [3], sql: [14, 18] }, ]; export type CompareRow = { feature: string; kysely: string; prisma: string; + sources: { kysely: string; prisma: string }; }; /** Columns run Feature, Kysely, Prisma ORM: alphabetical by product. */ @@ -33,105 +109,377 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ feature: "Library price", kysely: "Free. MIT license.", prisma: "Free. Apache License 2.0.", + sources: { + kysely: "https://github.com/kysely-org/kysely/blob/master/LICENSE", + prisma: "https://github.com/prisma/prisma/blob/main/LICENSE", + }, }, { feature: "Release status", kysely: - "Version 0.29.6 on npm, so not yet a 1.0 release. The getting started guide requires TypeScript 5.4 or newer, and strict mode.", + "Version 0.29.7 on npm, so not yet a 1.0 release. The getting started guide requires TypeScript 5.4 or newer, and strict mode.", prisma: - "Prisma ORM 8 is a release candidate, and it is what npm install prisma gives you. The final release is still to come.", + "Prisma ORM 8 is a release candidate, and it is what `npm install prisma` gives you. The final release is still to come.", + sources: { + kysely: "https://www.npmjs.com/package/kysely", + prisma: "https://www.prisma.io/docs/orm", + }, }, { feature: "Data model", kysely: - "TypeScript interfaces you provide. A dialect also needs its driver, such as pg for PostgreSQL.", + "TypeScript interfaces you provide. A dialect also needs its driver, such as `pg` for PostgreSQL.", prisma: - "A contract file, contract.prisma. contract emit writes the types. There is no @prisma/client package.", + "A contract file, `contract.prisma`. `contract emit` writes the types. `contract infer` writes a starter contract from PostgreSQL tables you already have. There is no `@prisma/client` package.", + sources: { + kysely: "https://kysely.dev/docs/getting-started", + prisma: "https://www.prisma.io/docs/cli/contract-infer", + }, }, { feature: "Queries", - kysely: "A fluent SQL builder: selectFrom, where, and the other clauses in the guide.", + kysely: "A fluent SQL builder: `selectFrom`, `where`, and the other clauses in the guide.", prisma: "An ORM client for model queries, a SQL query builder on PostgreSQL and SQLite, and raw SQL for a statement neither API expresses.", + sources: { + kysely: "https://kysely.dev/docs/getting-started", + prisma: "https://www.prisma.io/docs/orm/reference/sql-query-builder", + }, }, { feature: "Migrations", kysely: "up and down functions you write. Pending migrations run in alphanumeric order, with an option for unordered migrations.", prisma: - "migration plan writes migration.ts from the contract. On PostgreSQL, migration show prints the SQL. db migrate runs ops.json, not the TypeScript file.", + "`migration plan` writes `migration.ts` from the contract. On PostgreSQL, `migration show` prints the SQL. `db migrate` runs `ops.json`, not the TypeScript file.", + sources: { + kysely: "https://kysely.dev/docs/migrations", + prisma: "https://www.prisma.io/docs/cli/migration-show", + }, }, { feature: "Databases", - kysely: "Built-in dialects for PostgreSQL, MySQL, Microsoft SQL Server, SQLite, and PGlite.", + kysely: + "Built-in dialects for PostgreSQL, MySQL, Microsoft SQL Server, SQLite, and PGlite. See [database support](#databases).", prisma: - "PostgreSQL is a release candidate. MongoDB is early access. SQLite is experimental. MySQL, MariaDB, SQL Server, and CockroachDB stay on Prisma ORM 7.", + "PostgreSQL, MongoDB, and SQLite in Prisma ORM 8. The rest stay on Prisma ORM 7. See [database support](#databases).", + sources: { + kysely: "https://kysely.dev/docs/dialects", + prisma: "https://www.prisma.io/docs/orm/supported-databases", + }, }, { feature: "Hosted database", kysely: "None. Kysely is a library.", prisma: "[Prisma Postgres](/docs/postgres) is a separate product. Any Postgres client works with it, so Prisma ORM is optional. [Prisma Compute](/docs/compute) is separate app hosting.", + sources: { + kysely: "https://kysely.dev/docs/getting-started", + prisma: "https://www.prisma.io/docs/postgres", + }, }, ]; -export const KYSELY_FITS = [ - "You want queries written as SQL clauses, in the TypeScript you keep.", - "Table types already live in TypeScript, and a contract file would describe the same tables again.", - "Several people add migration files in parallel. Kysely runs them in alphanumeric order, and can allow unordered migrations.", - "The database is MySQL, Microsoft SQL Server, or PGlite.", +/** + * Kysely: core and community dialects on kysely.dev/docs/dialects. Prisma ORM 8: + * /docs/orm/supported-databases. Prisma ORM 7: /docs/orm/v7/reference/supported-databases. + * `null` is no support. + */ +export type DatabaseStatus = + | "Built-in" + | "Community dialect" + | "Release candidate" + | "Early access" + | "Experimental" + | "ORM 7 only" + | "Supported" + | null; + +/** Statuses for something you can build on today without a preview label. */ +export const FULL_SUPPORT: readonly DatabaseStatus[] = [ + "Built-in", + "Release candidate", + "Supported", ]; -export const PRISMA_FITS = [ - "One contract should drive model queries, the SQL query builder, and migrations.", - "Related records should be part of the model API, as in db.orm.public.User.include.", - "You already have PostgreSQL tables, and contract infer should write the contract from them.", - "migration plan should write the migration from the contract without connecting to the database.", +export const DATABASE_SOURCES = { + kysely: "https://kysely.dev/docs/dialects", + orm8: "https://www.prisma.io/docs/orm/supported-databases", + orm7: "https://www.prisma.io/docs/orm/v7/reference/supported-databases", +} as const; + +export const DATABASE_MATRIX: readonly { + database: string; + kysely: DatabaseStatus; + orm8: DatabaseStatus; + orm7: DatabaseStatus; +}[] = [ + { database: "PostgreSQL", kysely: "Built-in", orm8: "Release candidate", orm7: "Supported" }, + { database: "MySQL", kysely: "Built-in", orm8: "ORM 7 only", orm7: "Supported" }, + { database: "MariaDB", kysely: "Community dialect", orm8: "ORM 7 only", orm7: "Supported" }, + { database: "Microsoft SQL Server", kysely: "Built-in", orm8: "ORM 7 only", orm7: "Supported" }, + { database: "SQLite", kysely: "Built-in", orm8: "Experimental", orm7: "Supported" }, + { database: "CockroachDB", kysely: null, orm8: "ORM 7 only", orm7: "Supported" }, + { database: "MongoDB", kysely: null, orm8: "Early access", orm7: "Supported" }, + { database: "PGlite", kysely: "Built-in", orm8: null, orm7: null }, ]; -export const APART = [ +/** What each side costs. Figures checked against the pricing page and each license. */ +export const PRICING: readonly { + label: string; + kysely: string; + prisma: string; + sources: { kysely: string; prisma: string }; +}[] = [ { - title: "One contract for queries and migrations", - body: "You describe models in contract.prisma. contract emit writes the types, and migration plan writes a TypeScript migration from that contract. On PostgreSQL, migration show prints the SQL the migration will run.", + label: "Library license and cost", + kysely: "Free, MIT", + prisma: "Free, Apache 2.0", + sources: { + kysely: "https://github.com/kysely-org/kysely/blob/master/LICENSE", + prisma: "https://github.com/prisma/prisma/blob/main/LICENSE", + }, }, { - title: "A model API and a SQL query builder", - body: "The ORM client answers model queries. The SQL query builder, on PostgreSQL and SQLite, takes joins and aggregates the client does not expose. Raw SQL covers a statement neither API expresses.", + label: "Paid plans", + kysely: "None", + prisma: "None for the ORM", + sources: { + kysely: "https://kysely.dev/docs/getting-started", + prisma: "https://www.prisma.io/docs/orm", + }, }, { - title: "A contract from tables you already have", - body: "contract infer reads a PostgreSQL database and writes contract.prisma. It does what prisma db pull did in Prisma ORM 7. The rows stay in the database.", + label: "Hosted database", + kysely: "None. You bring your own.", + prisma: + "[Prisma Postgres](/docs/postgres), optional. It works with any Postgres client, Kysely included.", + sources: { + kysely: "https://kysely.dev/docs/getting-started", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + +export const SAME_JOB: readonly { task: string; kysely: string; prisma: string }[] = [ + { + task: "Define tables", + kysely: "A TypeScript `Database` interface, with one property per table.", + prisma: "Models in `contract.prisma`.", }, { + task: "Generate types", + kysely: + "Nothing to generate if you write the interface by hand. Community generators such as `kysely-codegen` write it for you.", + prisma: "`contract emit` writes the types from the contract.", + }, + { + task: "Read an existing database", + kysely: + "`kysely-codegen` connects to PostgreSQL, MySQL, SQLite, or SQL Server and writes the `Database` interface.", + prisma: "Inspects a live database and writes a starter `contract.prisma`.", + }, + { + task: "Write a migration", + kysely: + "`up` and `down` functions you write with `db.schema`. `kysely migrate make` from `kysely-ctl` creates the file.", + prisma: "`migration plan` writes `migration.ts` from the contract.", + }, + { + task: "Preview query SQL", + kysely: "`.compile()` on a query returns its SQL and parameters without running it.", + prisma: "`.build()` on the SQL query builder returns the query without running it.", + }, + { + task: "Preview migration SQL", + kysely: + "You read the SQL you wrote in the `up` function. There is no separate preview command.", + prisma: + "A CLI preview prints a migration's operations and statement preview, without connecting to the database.", + }, + { + task: "Run migrations", + kysely: + "`Migrator` from `kysely/migration`, with `migrateToLatest()`. `kysely-ctl` wraps it as `kysely migrate latest`.", + prisma: "`db migrate` applies the pending migrations on disk.", + }, +]; + +export const APART = [ + { + id: "model-api", + title: "A model API and a SQL query builder", + body: "The ORM client answers model queries. The SQL query builder, on PostgreSQL and SQLite, takes joins and aggregates the client does not expose. Raw SQL covers a statement neither API expresses.", + }, + { + id: "offline-migration-plan", title: "A migration plan that does not need the database", - body: "migration plan reads the contract and writes the migration. Planning does not connect to a database, so it can run without database credentials. On PostgreSQL, migration show prints the SQL before anything runs.", + body: "`migration plan` reads the contract and writes the migration. Planning does not connect to a database, so it can run without database credentials.", }, ]; export const WRONG_CHOICE = [ - "You want a SQL query builder and no contract file. Kysely takes a TypeScript Database interface, a dialect, and that dialect's driver.", + "You want a SQL query builder and no contract file. Kysely takes a TypeScript `Database` interface, a dialect, and that dialect's driver.", "You need MySQL, MariaDB, SQL Server, or CockroachDB from the current Prisma ORM 8 packages. Those databases are not in ORM 8 yet. [Prisma ORM 7](/docs/orm/v7) still supports them, and Kysely has built-in dialects for MySQL, SQL Server, and SQLite. See [supported databases](/docs/orm/supported-databases).", ]; export const FAQ: readonly { question: string; answer: string }[] = [ { - question: "Is Kysely an ORM?", - answer: - "Kysely is a type-safe SQL query builder. You describe tables as TypeScript interfaces and write queries with a fluent SQL API. It does not generate queries from a contract file. Source: [Kysely getting started](https://kysely.dev/docs/getting-started), October 2026.", + question: "Does Prisma generate database migrations for you, unlike Kysely?", + answer: `Yes. Kysely ships a migrator that runs the \`up\` and \`down\` functions you write by hand, so many teams add a separate tool to generate them. Prisma writes the migration for you from your schema. In Prisma 7, \`prisma migrate dev\` compares your schema with the database, writes a plain SQL file you can edit, and applies it, while \`prisma migrate deploy\` applies migrations in production. Prisma 8 (release candidate) keeps the idea with new commands: \`prisma migration plan\` writes a TypeScript migration you can review, and \`prisma db migrate\` applies it. To try it, [Prisma Postgres](https://www.prisma.io/postgres) has a free plan with 50 databases, 2 GB and 200k operations a month, no card needed. + +\`\`\`bash +# Prisma 7 +npx prisma migrate dev --name add_user_phone + +# Prisma 8 (release candidate) +npx prisma migration plan --name add_user_phone +npx prisma db migrate +\`\`\``, }, { - question: "Is Prisma ORM 8 a final release?", - answer: - "No. Prisma ORM 8 is a release candidate, and it is what npm install prisma gives you today. PostgreSQL is the release-candidate database. MongoDB is early access, and SQLite is experimental. Source: [Prisma ORM](/docs/orm) and [supported databases](/docs/orm/supported-databases), October 2026.", + question: "How do I query nested relations in TypeScript without writing joins by hand?", + answer: `Kysely's own docs are upfront that it has no concept of relations, so you nest rows with SQL JSON helpers such as \`jsonArrayFrom\`. Prisma knows your relations from the schema, so you just ask for them. In Prisma 7 you use \`include\` or \`select\`, nest as deep as you like, and filter parents by their children with \`some\`, \`every\` and \`none\`. Prisma 7 can load it all in one SQL query with \`relationLoadStrategy: "join"\`, which is still in Preview. Prisma 8 (release candidate) uses a chained \`.include()\` that reads the relation in the same SQL statement as the parent, with a callback to filter, sort or limit it. + +\`\`\`ts +// Prisma 7 +const users = await prisma.user.findMany({ + include: { posts: { where: { published: true }, take: 5 } }, +}); + +// Prisma 8 (release candidate) +const users = await db.orm.public.User + .include("posts", (p) => p.where({ published: true }).limit(5)) + .all(); +\`\`\``, }, { - question: "Can I connect Kysely to Prisma Postgres?", - answer: - "Yes. Prisma Postgres is PostgreSQL, and Prisma ORM is optional. Pass the connection string to Kysely's PostgresDialect and the pg driver. The [Kysely quickstart](/docs/prisma-postgres/quickstart/kysely) shows the setup.", + question: "Can Prisma create a record and its related records in one call?", + answer: `Yes. With a query builder you usually write each insert yourself and wrap them in a transaction. Prisma 7 has nested writes: inside one \`create\` or \`update\` you can create, connect, update or delete related records, and Prisma runs it all as a single transaction, rolling everything back if any part fails. Prisma 8 (release candidate) supports nested \`create\`, \`connect\` and \`disconnect\`. Nested update, upsert, delete and \`connectOrCreate\` are not in Prisma 8 yet, so for those you change the related rows through their own model inside \`db.transaction(...)\`. + +\`\`\`ts +// Prisma 7: one query, one transaction +await prisma.user.create({ + data: { + email: "elsa@prisma.io", + posts: { create: [{ title: "Hello" }, { title: "Second post" }] }, + }, +}); + +// Prisma 8 (release candidate) +await db.orm.public.User.create({ + email: "elsa@prisma.io", + posts: (p) => p.create([{ title: "Hello", published: false }]), +}); +\`\`\``, }, { - question: "Which TypeScript setup does Kysely require?", - answer: - "Kysely's getting started guide requires TypeScript 5.4 or newer, and strict mode in tsconfig.json. Source: [Kysely getting started](https://kysely.dev/docs/getting-started), October 2026.", + question: "How do I set updatedAt automatically in TypeScript?", + answer: `Kysely has no built-in way to do it, so you set the column in every update, add a database trigger, or write your own plugin. In Prisma you mark the field once in the schema and Prisma fills it in on every write. In Prisma 7 that is the \`@updatedAt\` attribute. In Prisma 8 (release candidate) it is \`temporal.updatedAt()\`, which sets the time on every create and update from your application's clock. One thing to know: because the value comes from the app, a row you insert with raw SQL has to supply it. + +\`\`\`prisma +// Prisma 7: schema.prisma +model Post { + id Int @id @default(autoincrement()) + updatedAt DateTime @updatedAt +} + +// Prisma 8 (release candidate): contract.prisma +// use prisma-8 +model Post { + id Int @id @default(autoincrement()) + updatedAt temporal.updatedAt() +} +\`\`\``, + }, + { + question: "Is Prisma simpler than Kysely for everyday CRUD?", + answer: `For everyday reads and writes, usually yes, because Prisma gives you methods for each model. Kysely asks you to spell out each step, which is great for complex SQL but adds up for simple CRUD. In Prisma 7, a create returns the new record by default. Prisma 8 (release candidate) trims it further: no \`data\` wrapper, and an update or delete without a \`.where(...)\` does not type-check, so you can't wipe a table by accident. When you do want SQL, Prisma 7 has \`$queryRaw\` and TypedSQL (Preview), and Prisma 8 has a typed SQL query builder. + +\`\`\`ts +// Kysely +await db.insertInto("user").values({ email, name }).returningAll().executeTakeFirstOrThrow(); + +// Prisma 7 +await prisma.user.create({ data: { email, name } }); + +// Prisma 8 (release candidate) +await db.orm.public.User.create({ email, name }); +\`\`\``, + }, + { + question: "Should I start with Prisma 7 or Prisma 8?", + answer: `If you need something stable today, use Prisma 7. It is the current stable release, it supports PostgreSQL, MySQL, SQLite, SQL Server, CockroachDB and MongoDB, and it will keep getting bug fixes and security updates for 18 months after Prisma 8 reaches general availability. Prisma 8 is a release candidate: PostgreSQL is the most complete, MongoDB is in early access, SQLite is experimental, and MySQL is not supported yet. Note that a plain \`npm install prisma\` now installs the Prisma 8 release candidate, so pin \`prisma@7\` if you want version 7. + +\`\`\`bash +# Prisma 7 (stable) +npm install --save-dev prisma@7 + +# Prisma 8 (release candidate) +npm install --save-dev prisma +npm install @prisma/orm-postgres +\`\`\``, }, ]; + +/** Short Kysely table types for the User and Post tables in the same-query example. */ +export const SWITCH_KYSELY_TYPES = `interface Database { + User: { + id: number; + email: string; + name: string | null; + }; + Post: { + id: number; + title: string; + authorId: number; + }; +}`; + +/** + * Representative `contract.prisma` matching those tables, in the shape + * `contract infer` writes (PSL, `// use prisma-8`, namespace public). + */ +export const SWITCH_PRISMA_CONTRACT = `// use prisma-8 +// Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. + +namespace public { + model User { + id Int @id + email String + name String? + posts Post[] + } + + model Post { + id Int @id + title String + authorId Int + author User @relation(fields: [authorId], references: [id]) + } +}`; + +/** Four commands from the Prisma ORM 8 existing-PostgreSQL guide. */ +export const SWITCH_COMMANDS = `npx prisma@latest orm init --yes --target postgres --authoring psl --write-env +npx prisma contract infer --output ./src/prisma/contract.prisma +npx prisma contract emit +npx prisma db sign`; + +export const TOOLS = { + kysely: { + name: "Kysely", + softwareVersion: "0.29.7", + license: "https://opensource.org/licenses/MIT", + codeRepository: "https://github.com/kysely-org/kysely", + programmingLanguage: "TypeScript", + url: "https://kysely.dev", + }, + prisma: { + name: "Prisma ORM", + softwareVersion: "8", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/orm", + }, +} as const; diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 5c451b57dde..865744bab21 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,16 +1,35 @@ import Link from "next/link"; +import { + CodeBlock, + CodeBlockTab, + CodeBlockTabs, + CodeBlockTabsList, + CodeBlockTabsTrigger, +} from "@prisma/eclipse"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { CheckBold } from "@/components/icons/forma"; -import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; -import { RoleKicker } from "@/components/brand/role-kicker"; -import { Texture } from "@/components/brand/texture"; -import { CtaBurst } from "@/components/sections/cta-burst"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK, QUIET_NOTE } from "../table-styles"; +import { highlight, lineGroupMap } from "./highlight"; +import { SameQueryHighlight } from "./same-query"; +import { TabCodeCopy } from "../tab-code-copy"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -19,478 +38,523 @@ import { CHOOSE_PRISMA, COMPARE_ROWS, FAQ, - KYSELY_FITS, + KYSELY_AUTHOR, KYSELY_TITLE, - PRISMA_FITS, + KYSELY_UPDATED, + PRICING, + SAME_QUERY, + SAME_QUERY_LINE_GROUPS, + SWITCH_COMMANDS, + SWITCH_KYSELY_TYPES, + SWITCH_PRISMA_CONTRACT, WRONG_CHOICE, + DATABASE_MATRIX, + type DatabaseStatus, + FULL_SUPPORT, + SAME_JOB, } from "./_content"; const INLINE = - "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-black/60"; + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; + +/** + * Full support reads as body text; preview and ORM 7 labels are muted; No is + * no support. Called as a function so the Markdown walker sees the elements. + */ +function databaseStatus(status: DatabaseStatus) { + if (status === null) { + return No; + } + return ( + + {status} + + ); +} + +/** + * The site does not build the eclipse tokens these components use + * (`rounded-square`, `border-stroke-neutral`, `type-text-sm-strong`, the active + * tab bar), so the same values are set with site utilities. + */ +const CODE_TABS = "my-0 overflow-hidden rounded-[0.625rem] border border-border bg-white"; +const CODE_TAB_LIST = "flex min-w-0 flex-1 flex-row overflow-x-auto px-2"; +const CODE_TAB = + "relative inline-flex min-h-11 items-center px-2 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-[state=active]:text-primary data-[state=active]:[&>div]:bg-primary"; +const CODE_BLOCK = "not-fumadocs-codeblock my-0 rounded-none border-0 bg-white [&>div]:py-3.5"; +const CODE_BLOCK_WITH_COPY = `${CODE_BLOCK} rounded-[0.625rem] border border-border [&>[role=region]]:mr-11 [&_button]:relative [&_button]:text-muted-foreground [&_button]:after:absolute [&_button]:after:-inset-2.5 [&_button]:after:content-['']`; const PROOF = TESTIMONIALS.filter( (item) => item.name === "Ron Cohen" || item.name === "Omar López", ); +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +const SOURCES = [ + { + product: "Kysely", + links: [ + { label: "Getting started", href: "https://kysely.dev/docs/getting-started" }, + { label: "kysely on npm", href: "https://www.npmjs.com/package/kysely" }, + { label: "Dialects", href: "https://kysely.dev/docs/dialects" }, + { label: "Generating types", href: "https://kysely.dev/docs/generating-types" }, + { label: "Migrations", href: "https://kysely.dev/docs/migrations" }, + { + label: "Splitting query building and execution", + href: "https://kysely.dev/docs/recipes/splitting-query-building-and-execution", + }, + { label: "kysely-ctl", href: "https://github.com/kysely-org/kysely-ctl" }, + { label: "kysely-codegen", href: "https://github.com/RobinBlomberg/kysely-codegen" }, + { label: "Relations recipe", href: "https://kysely.dev/docs/recipes/relations" }, + { label: "MIT license", href: "https://github.com/kysely-org/kysely/blob/master/LICENSE" }, + ], + }, + { + product: "Prisma ORM and Prisma Postgres", + links: [ + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Supported databases", href: "/docs/orm/supported-databases" }, + { + label: "Prisma ORM 7 supported databases", + href: "/docs/orm/v7/reference/supported-databases", + }, + { label: "contract emit", href: "/docs/cli/contract-emit" }, + { label: "contract infer", href: "/docs/cli/contract-infer" }, + { label: "migration plan", href: "/docs/cli/migration-plan" }, + { label: "migration show", href: "/docs/cli/migration-show" }, + { label: "db migrate", href: "/docs/cli/db-migrate" }, + { label: "ORM client reference", href: "/docs/orm/reference/orm-client" }, + { label: "How migrations work", href: "/docs/orm/migrations/how-migrations-work" }, + { label: "SQL query builder", href: "/docs/orm/reference/sql-query-builder" }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + /** Build the shared content tree for the HTML page and its Markdown rendition. */ -export function getKyselyPageContent() { - return ( - <> -
-
-
-
-
-
- -
-
- Compare -

- {KYSELY_TITLE} -

-

- Pick{" "} - - Prisma ORM - {" "} - when a contract file should drive typed queries and migrations. Prisma ORM 8 is a - release candidate, and it is what{" "} - npm install prisma gives you. Pick{" "} - +function buildKyselyPage(minutes: number) { + const kyselyGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "kysely"); + const prismaGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "prisma"); + const sqlGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "sql"); + + return [ +

+

+ {KYSELY_TITLE} +

+

+ Both give you type-safe database access in TypeScript, from opposite ends.{" "} + + Prisma ORM + {" "} + starts from a contract file: you describe your models once, and typed queries and migrations + follow from it.{" "} + + Kysely + {" "} + starts from SQL: you write each query as a fluent TypeScript builder over table types you + keep yourself. +

+ {comparisonHeroCtas("orm")} + {comparisonByline({ author: KYSELY_AUTHOR, updated: KYSELY_UPDATED, minutes })} + {comparisonDisclosure({ competitor: "Kysely", checked: CHECKED })} +
, +
+
+ + When should I choose Kysely or Prisma ORM? + + {comparisonChooseTable({ + competitor: "Kysely", + prisma: "Prisma ORM", + competitorItems: CHOOSE_KYSELY, + prismaItems: CHOOSE_PRISMA, + })} +
+ +
+ + What does the same query look like in each? + +

+ Each snippet reads one user by email with their posts: Kysely nests the posts with its{" "} + jsonArrayFrom helper, and Prisma ORM loads them with include. + The SQL tab is representative of the statements both send, not captured from a live run. +

+ + +
+ + Kysely - {" "} - when you want a type-safe SQL query builder and table types you keep in TypeScript. - Prisma makes Prisma ORM. Kysely is an independent open-source project. The other ORM - comparison is{" "} - - Prisma ORM vs Mongoose - - , and all{" "} - - Prisma comparisons - {" "} - are listed in one place. -

-

- Every competitor figure on this page has a source, listed under the table and - checked {CHECKED}. -

-
- - Read the ORM docs - - - See which fits - -
+
+ + Prisma ORM + + + SQL sent + +
+
-
-
+ + + {highlight(SAME_QUERY.kysely, "typescript", kyselyGroups)} + + + + + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} + + + + + {highlight(SAME_QUERY.sql, "sql", sqlGroups)} + + + +

+ + + posts include + + + + email filter + + Tinted lines match across the open tab. Hover one to emphasize its pair. +

+
-
-
-
-

- Choose Prisma ORM if -

-
    - {CHOOSE_PRISMA.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
-

- Choose Kysely if -

-
    - {CHOOSE_KYSELY.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
+
+ + How do Prisma ORM and Kysely compare feature by feature? + +

+ Kysely is a SQL query builder. Prisma ORM is a contract-based ORM that also includes a SQL + query builder on PostgreSQL and SQLite. Columns are in alphabetical order. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.kysely), renderFaqAnswer(row.prisma)], + }))} + />
-
-
-

- What each one supports -

-

- Columns are alphabetical: Kysely, then Prisma ORM. One is a SQL query builder. The other - is a contract-based ORM that also includes a SQL query builder on PostgreSQL and SQLite. -

-
-
- - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
- Feature - - Kysely - - Prisma ORM -
- {row.feature} - - {row.kysely} - - {renderFaqAnswer(row.prisma)} -
+
+ + Which databases do Prisma ORM and Kysely support? + +

+ Kysely's dialects next to the two Prisma ORM lines. Prisma ORM 7 is the generally + available release, and it supports every database here except PGlite. No means no support. +

+ ({ + label: row.database, + cells: [databaseStatus(row.kysely), databaseStatus(row.orm8), databaseStatus(row.orm7)], + }))} + /> +

+ MariaDB on Kysely is the community{" "} + + kysely-mariadb + {" "} + dialect. Sources:{" "} + + Kysely dialects + + ,{" "} + + Prisma ORM 8 supported databases + + , and{" "} + + Prisma ORM 7 supported databases + + , {CHECKED}. +

+
+ +
+ + {"What does Prisma ORM do that Kysely doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

-
-

- Kysely cells:{" "} - - Getting started - - ,{" "} - - npm - - ,{" "} - - Migrations - - , and the{" "} - - MIT license - - , {CHECKED}. Prisma ORM cells:{" "} - - Prisma ORM - - ,{" "} - - supported databases - - ,{" "} - - migrations - - ,{" "} - - the SQL query builder - - , and the{" "} - - Apache License 2.0 - - , {CHECKED}. Hosting is on the{" "} - - pricing page - - . -

+ ))}
-
-
-
-

- Where Kysely works well -

-

- Kysely is a SQL query builder. Its{" "} - - getting started guide - {" "} - shows selectFrom and{" "} - where, and lists joins among the other SQL - features. Each built-in dialect needs its driver. -

-
    - {KYSELY_FITS.map((item) => ( -
  • - {item} -
  • - ))} -
-
-
-

- Where Prisma ORM fits better -

-

- Prisma ORM fits a team that wants the contract, the model queries, and the migrations - to come from one file. The{" "} - - ORM docs +

+ + How much do Prisma ORM and Kysely cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.kysely), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. The{" "} + + pricing page {" "} - describe that file as the contract. -

-
    - {PRISMA_FITS.map((item) => ( -
  • - {item} -
  • - ))} -
-
-
+ lists the paid plans. + + } + />
-
-
-

- What sets Prisma ORM apart -

-
- {APART.map((item) => ( -
-

{item.title}

-

- {item.body} -

-
- ))} -
-
+
+ + When is Prisma ORM the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
-
-
-

- Pricing -

-
-
-

Kysely

-

- The library is free under the{" "} - - MIT license - - . There is no Kysely hosting plan. You bring the database. Checked {CHECKED}. -

-
-
-

Prisma ORM and Prisma Postgres

-

- Prisma ORM is free under the{" "} - - Apache License 2.0 - - . Prisma Postgres is optional, and Prisma ORM is optional with it. The Free plan is - $0, with 200,000 operations a month, 2 GB of storage, and 50 databases ( - - pricing page - - , {CHECKED}). Prisma Compute is separate app hosting. -

-
-
-
+
+ + How does each task look in Kysely and Prisma ORM? + +

+ What each step of the usual workflow looks like on each side, for a team moving in either + direction. +

+ ({ + label: row.task, + cells: [renderFaqAnswer(row.kysely), renderFaqAnswer(row.prisma)], + }))} + />
-
-
-

- When Prisma ORM is the wrong choice -

-
    - {WRONG_CHOICE.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
-
+
+ + How do I switch from Kysely to Prisma ORM? + +

+ For PostgreSQL, these four commands from the{" "} + + existing-database guide + {" "} + add Prisma ORM beside the tables you already have. The Node.js versions that guide expects + still apply. Signing records that the database already matches the contract, so the first{" "} + migration plan starts from your tables instead of an empty database. The rows + stay where they are. +

+

+ Representative types for the User and Post tables in the query above, then the four + commands. +

+ +
+ + + Kysely types + + + contract.prisma + + + +
+ + + {highlight(SWITCH_KYSELY_TYPES)} + + + + + {highlight(SWITCH_PRISMA_CONTRACT, "prisma")} + + +
+ {highlight(SWITCH_COMMANDS, "bash")} +
    +
  1. + Later changes go through{" "} + + contract emit and migration plan + + . On PostgreSQL, migration show prints the SQL. +
  2. +
-
-
-
-

- Move from Kysely to Prisma ORM -

-
    -
  1. - For PostgreSQL, run{" "} - npx prisma@latest orm init in the app. The{" "} - - existing-database guide - {" "} - lists the Node.js versions it expects. -
  2. -
  3. - Run npx prisma contract infer to write{" "} - contract.prisma from the tables, then{" "} - npx prisma contract emit and{" "} - npx prisma db sign. Signing records that - the database already matches the contract, so the first{" "} - migration plan starts from your tables - instead of an empty database. The rows stay where they are. -
  4. -
  5. - Later changes go through{" "} - - contract emit and migration plan - - . On PostgreSQL, migration show prints the SQL. -
  6. -
-
-
-

- Move from Prisma ORM to Kysely -

-
    -
  1. Keep the database and the connection string.
  2. -
  3. - Add a TypeScript Database interface that - matches the tables. A community generator can emit those types from a Prisma schema. - Prisma documents it on the{" "} - - Prisma ORM 7 generators - {" "} - page. It is not part of Prisma ORM. -
  4. -
  5. - Create one Kysely instance with the dialect that matches the database, plus that - dialect's driver. On PostgreSQL that is{" "} - PostgresDialect and{" "} - pg. On Prisma Postgres, follow the{" "} - - Kysely quickstart - - . Prisma ORM is optional there. -
  6. -
-
-
+
+ + How do I switch from Prisma ORM to Kysely? + +
    +
  1. Keep the database and the connection string.
  2. +
  3. + Add a TypeScript Database interface that matches the tables. A community + generator can emit those types from a Prisma schema. Prisma documents it on the{" "} + + Prisma ORM 7 generators + {" "} + page. It is not part of Prisma ORM. +
  4. +
  5. + Create one Kysely instance with the dialect that matches the database, plus that + dialect's driver. On PostgreSQL that is PostgresDialect and{" "} + pg. On Prisma Postgres, follow the{" "} + + Kysely quickstart + + . Prisma ORM is optional there. +
  6. +
-
-
-

- What teams on Prisma say -

-

- These two quotes are on prisma.io today, from teams using Prisma ORM for type safety. -

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-
+
+ + What do teams say about Prisma ORM? + +

Two teams on what type safety in Prisma ORM does for them.

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )}
-
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("orm"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • ))} - +
-
-
+ ))} +
+
, + ]; +} - - - ); +const KYSELY_READING = articleReadingStats(buildKyselyPage(1)); + +export function getKyselyReadingStats() { + return articleReadingStats(buildKyselyPage(KYSELY_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getKyselyPageContent() { + return buildKyselyPage(KYSELY_READING.minutes); } -/** Render the comparison using its shared content tree. */ +/** Render the comparison in the guide article frame. */ export function KyselyPage() { - return getKyselyPageContent(); + return {getKyselyPageContent()}; } diff --git a/apps/site/src/app/compare/kysely/facts.ts b/apps/site/src/app/compare/kysely/facts.ts new file mode 100644 index 00000000000..bf11decad0e --- /dev/null +++ b/apps/site/src/app/compare/kysely/facts.ts @@ -0,0 +1,110 @@ +import { + COMPARE_ROWS, + DATABASE_MATRIX, + DATABASE_SOURCES, + KYSELY_PATH, + KYSELY_PUBLISHED, + KYSELY_UPDATED, + PRICING, + TOOLS, +} from "./_content"; +import { getKyselyReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +function statusValue(status: string | null): string { + return status ?? "No"; +} + +/** Machine-readable feature, database, and pricing cells with a source on every value. */ +export function getKyselyFacts() { + const dateChecked = KYSELY_UPDATED.iso; + return { + path: KYSELY_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + kysely: { + value: row.kysely, + source: row.sources.kysely, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + databases: DATABASE_MATRIX.map((row) => ({ + database: row.database, + kysely: { + value: statusValue(row.kysely), + source: DATABASE_SOURCES.kysely, + dateChecked, + } satisfies FactValue, + orm8: { + value: statusValue(row.orm8), + source: DATABASE_SOURCES.orm8, + dateChecked, + } satisfies FactValue, + orm7: { + value: statusValue(row.orm7), + source: DATABASE_SOURCES.orm7, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + kysely: { + value: row.kysely, + source: row.sources.kysely, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getKyselyAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.kysely.name, + softwareVersion: TOOLS.kysely.softwareVersion, + license: TOOLS.kysely.license, + codeRepository: TOOLS.kysely.codeRepository, + programmingLanguage: TOOLS.kysely.programmingLanguage, + url: TOOLS.kysely.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getKyselyArticleFields() { + const { wordCount, timeRequired } = getKyselyReadingStats(); + return { + datePublished: KYSELY_PUBLISHED.iso, + dateModified: KYSELY_UPDATED.iso, + wordCount, + timeRequired, + about: getKyselyAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/kysely/highlight.tsx b/apps/site/src/app/compare/kysely/highlight.tsx new file mode 100644 index 00000000000..bbea0396175 --- /dev/null +++ b/apps/site/src/app/compare/kysely/highlight.tsx @@ -0,0 +1,119 @@ +import type { CSSProperties } from "react"; +import { createHighlighterCoreSync } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs"; +import githubLightHighContrast from "shiki/themes/github-light-high-contrast.mjs"; +import bash from "shiki/langs/bash.mjs"; +import prisma from "shiki/langs/prisma.mjs"; +import sql from "shiki/langs/sql.mjs"; +import typescript from "shiki/langs/typescript.mjs"; + +export type HighlightLang = "typescript" | "sql" | "prisma" | "bash"; + +const LANG_IDS: Record = { + typescript: "typescript", + sql: "sql", + prisma: "prisma", + bash: "bash", +}; + +/** + * High-contrast GitHub themes so parameter tokens clear 4.5:1 (the default + * github-light orange `#e36209` is 3.49:1). `defaultColor: false` so tokens + * carry `--shiki-light` / `--shiki-dark`. + */ +const highlighter = createHighlighterCoreSync({ + themes: [githubLightHighContrast, githubDarkHighContrast], + langs: [typescript, sql, prisma, bash], + engine: createJavaScriptRegexEngine(), +}); + +/** The docs' `type-code-sm` inside a code block, with `--font-code` set by `page.tsx`. */ +const CODE_TYPE: CSSProperties = { + border: 0, + backgroundColor: "transparent", + fontFamily: + 'var(--font-code, ui-monospace), ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace', + fontFeatureSettings: '"ss01" on, "ss02" on, "ss05" on, "ss06" on', + fontSize: "0.8125rem", + lineHeight: "1.25rem", +}; + +/** Undoes the page's inline-code chip, which its prose wrapper applies to every `code`. */ +const BLOCK_CODE: CSSProperties = { + backgroundColor: "transparent", + padding: 0, + borderRadius: 0, + fontFamily: "inherit", + fontSize: "inherit", + fontWeight: 400, + color: "inherit", +}; + +const DATA_LANG: Record = { + typescript: "ts", + sql: "sql", + prisma: "prisma", + bash: "bash", +}; + +/** Persistent tints for same-query line groups. Cyan is the include; yellow is the filter. */ +const LINE_TINT: Record = { + posts: "bg-prism-cyan-100", + author: "bg-prism-cyan-100", + email: "bg-prism-yellow-100", + published: "bg-prism-yellow-100", +}; + +/** A highlighted `pre` with the docs' code block layout: one flex row per line. */ +export function highlight( + code: string, + lang: HighlightLang = "typescript", + lineGroups: Readonly> = {}, +) { + const { tokens } = highlighter.codeToTokens(code, { + lang: LANG_IDS[lang], + themes: { light: "github-light-high-contrast", dark: "github-dark-high-contrast" }, + defaultColor: false, + }); + return ( +
+      
+        {tokens.flatMap((line, index) => [
+          index > 0 ? "\n" : null,
+          
+            {line.length === 0
+              ? " "
+              : line.map((token) => (
+                  
+                    {token.content}
+                  
+                ))}
+          ,
+        ])}
+      
+    
+ ); +} + +/** Invert SAME_QUERY_LINE_GROUPS into a line-index map for one tab. */ +export function lineGroupMap( + groups: readonly { id: string; [tab: string]: string | readonly number[] }[], + tab: string, +): Record { + const map: Record = {}; + for (const group of groups) { + const lines = group[tab]; + if (!Array.isArray(lines)) continue; + for (const line of lines) map[line] = group.id; + } + return map; +} diff --git a/apps/site/src/app/compare/kysely/mona-sans-mono.woff2 b/apps/site/src/app/compare/kysely/mona-sans-mono.woff2 new file mode 100644 index 00000000000..f4ccd616f12 Binary files /dev/null and b/apps/site/src/app/compare/kysely/mona-sans-mono.woff2 differ diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 74d6e07bd98..9f4a538c856 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -1,20 +1,60 @@ +import localFont from "next/font/local"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; -import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; -import { FAQ, KYSELY_PATH, KYSELY_TITLE } from "./_content"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE } from "./_content"; +import { getKyselyArticleFields } from "./facts"; import { KyselyPage } from "./_sections"; +/** + * The docs' code face. The file is a copy of MonaSansMonoVF[wght].woff2 + * without brackets in the name so the preload href and @font-face url match + * (encoded `%5Bwght%5D` vs raw `[wght]` used to unused-preload the face). + */ +const monaSansMono = localFont({ + src: "./mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + export const metadata = getComparisonMetadata("kysely"); const faqStructuredData = createFaqStructuredData(KYSELY_PATH, [...FAQ], KYSELY_TITLE); +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("kysely"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getKyselyArticleFields(), + image: getComparisonOgImageUrl("kysely"), + author: comparisonAuthorPerson(KYSELY_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + /** Render the comparison and its article, breadcrumb and FAQ structured data. */ export default function Page() { return ( <> - + - +
+ +
); } diff --git a/apps/site/src/app/compare/kysely/same-query.tsx b/apps/site/src/app/compare/kysely/same-query.tsx new file mode 100644 index 00000000000..576c7b1ddcb --- /dev/null +++ b/apps/site/src/app/compare/kysely/same-query.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useCallback, type MouseEvent, type ReactNode } from "react"; + +/** + * Mapped lines keep a persistent tint. Hovering or focusing one emphasizes + * its counterparts in the open tab. Inactive tabs are not mounted, so the + * mapping is in-tab only. + */ +export function SameQueryHighlight({ children }: { children: ReactNode }) { + const mark = useCallback((event: MouseEvent | { target: EventTarget }) => { + const root = (event.target as HTMLElement | null)?.closest("[data-same-query]"); + if (!root) return; + const group = (event.target as HTMLElement | null) + ?.closest("[data-group]") + ?.getAttribute("data-group"); + for (const line of root.querySelectorAll("[data-group]")) { + line.classList.toggle("ring-1", Boolean(group) && line.dataset.group === group); + line.classList.toggle("ring-inset", Boolean(group) && line.dataset.group === group); + line.classList.toggle("ring-foreground/25", Boolean(group) && line.dataset.group === group); + } + }, []); + + const clear = useCallback((event: MouseEvent) => { + const root = event.currentTarget; + for (const line of root.querySelectorAll("[data-group]")) { + line.classList.remove("ring-1", "ring-inset", "ring-foreground/25"); + } + }, []); + + return ( +
mark(event)} + > + {children} +
+ ); +} diff --git a/apps/site/src/app/compare/mongoose.json/route.ts b/apps/site/src/app/compare/mongoose.json/route.ts new file mode 100644 index 00000000000..86c8f07c581 --- /dev/null +++ b/apps/site/src/app/compare/mongoose.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getMongooseFacts } from "../mongoose/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getMongooseFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/mongoose/_content.ts b/apps/site/src/app/compare/mongoose/_content.ts index 588bee9bb3e..be62c4e8bca 100644 --- a/apps/site/src/app/compare/mongoose/_content.ts +++ b/apps/site/src/app/compare/mongoose/_content.ts @@ -1,30 +1,86 @@ export const MONGOOSE_PATH = "/compare/mongoose"; -export const MONGOOSE_TITLE = "Prisma ORM vs Mongoose: type-safe data modeling beyond MongoDB"; +export const MONGOOSE_TITLE = + "Prisma ORM vs Mongoose: contract file or schema in application code?"; export const MONGOOSE_DESCRIPTION = - "Prisma ORM drives typed queries and migrations from one contract file, on PostgreSQL and MongoDB. Mongoose models MongoDB documents in application code. Which fits, with sourced figures."; + "Prisma ORM 8 types MongoDB queries, aggregations, and migrations from a contract file. Mongoose models documents in application code. Checked October 2026."; export const CHECKED = "October 2026"; +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const MONGOOSE_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const MONGOOSE_PUBLISHED = { iso: "2026-10-16", label: "October 2026" } as const; +export const MONGOOSE_UPDATED = { iso: "2026-10-19", label: "October 2026" } as const; + +/** Pages this comparison cites in the body, not only under Sources. */ +export const MONGO_BLOG_PRISMA_NEXT = + "https://www.mongodb.com/company/blog/technical/prisma-next-typescript-odm-you-always-wanted"; +export const PRISMA_MONGODB_PAGE = "/mongodb"; +export const PRISMA_MONGODB_BLOG = "/blog/mongodb-without-compromise"; + export const CHOOSE_PRISMA = [ - "You want models in a contract file, contract.prisma, for PostgreSQL or for MongoDB.", - "You want contract emit to write the query types. Prisma ORM 8 does not use the @prisma/client package. That package is Prisma ORM 7.", - "You want migration plan to write migration.ts from that contract. db migrate runs ops.json.", - "On PostgreSQL, you want the query to select parent records by their related records.", + "You want models in a contract file, `contract.prisma` — a contract is the file that describes your collections or tables.", + "You want `contract emit` to write the query types. Prisma ORM 8 does not use the `@prisma/client` package. That package is Prisma ORM 7.", + "You want a typed aggregation pipeline, polymorphic collections from `@@discriminator`, and `migration plan` to write `migration.ts` for indexes and JSON Schema validators. `db migrate` runs `ops.json`.", + "You also ship on PostgreSQL, where the release candidate includes the ORM client, a SQL query builder, and relation filters.", ]; export const CHOOSE_MONGOOSE = [ - "The application talks to MongoDB, and the schema sits next to the model in application code.", - "You want populate() to replace stored ids with documents from another collection.", + "You want the schema next to the model in application code, compiled with `mongoose.model`.", + "You want `populate()` to replace a stored id with a document from another collection.", "You want pre and post hooks on documents, queries, aggregates, and models.", - "You want MongoDB query operators in find(), as the getting started guide shows.", + "The database is MongoDB, and `find()` should take MongoDB query operators.", +]; + +/** Published posts with their authors, read from the same MongoDB `posts` and `users` collections. */ +export const SAME_QUERY = { + mongoose: `import { Post } from "./models/post"; + +const posts = await Post.find({ published: true }) + .populate("author");`, + prisma: `import { db } from "./src/prisma/db"; + +const posts = await db.orm.posts + .where({ published: true }) + .include("author") + .all();`, + /** + * Representative MongoDB commands both snippets send for that query. Labelled + * because they were not captured from a live run against one database. + */ + mongo: `// Representative commands matching both snippets above, not captured from a live run. + +// Mongoose populate: a find, then a second find for the authors +db.posts.find({ published: true }) +db.users.find({ _id: { $in: [...] } }) + +// Prisma ORM include: the same two-query pattern +db.posts.find({ published: true }) +db.users.find({ _id: { $in: [...] } })`, +} as const; + +/** Shared hover groups across the three same-query tabs (0-based Shiki line indexes). */ +export const SAME_QUERY_LINE_GROUPS: readonly { + id: string; + mongoose: readonly number[]; + prisma: readonly number[]; + mongo: readonly number[]; +}[] = [ + { id: "author", mongoose: [3], prisma: [4], mongo: [4, 8] }, + { id: "published", mongoose: [2], prisma: [3], mongo: [3, 7] }, ]; export type CompareRow = { feature: string; mongoose: string; prisma: string; + sources: { mongoose: string; prisma: string }; }; /** Columns run Feature, Mongoose, Prisma ORM: alphabetical by product. */ @@ -33,121 +89,289 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ feature: "Library price", mongoose: "Free. MIT license.", prisma: "Free. Apache License 2.0.", + sources: { + mongoose: "https://github.com/Automattic/mongoose/blob/master/LICENSE.md", + prisma: "https://github.com/prisma/prisma/blob/main/LICENSE", + }, }, { feature: "Release status", mongoose: - "The getting started guide is Mongoose v9.11.2. Mongoose 9.0.0 shipped on 21 November 2025.", + "Version 9.11.2 on npm. Mongoose 9.0.0 shipped on 21 November 2025. The getting started guide is this 9.x line.", prisma: - "Prisma ORM 8 is a release candidate, and it is what npm install prisma gives you. The final release is still to come. PostgreSQL is the release-candidate database. MongoDB is early access.", + "Prisma ORM 8 is a release candidate, and it is what `npm install prisma` gives you. The final release is still to come. PostgreSQL is the release-candidate database. MongoDB is early access.", + sources: { + mongoose: "https://www.npmjs.com/package/mongoose", + prisma: "https://www.prisma.io/docs/orm", + }, }, { feature: "Data model", - mongoose: "A Schema and a Model in application code. mongoose.model compiles the schema.", + mongoose: "A Schema and a Model in application code. `mongoose.model` compiles the schema.", prisma: - "A contract file, contract.prisma. On MongoDB you write that file yourself: contract infer does not read a MongoDB database. contract emit writes the types.", + "A contract file, `contract.prisma`. On MongoDB you write that file yourself: `contract infer` does not read a MongoDB database. `contract emit` writes the types. There is no `@prisma/client` package.", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/docs/guides/switch-to-prisma-orm/from-mongoose", + }, }, { feature: "Queries", - mongoose: "Model.find() and the other query helpers, with MongoDB query operators.", + mongoose: "`Model.find()` and the other query helpers, with MongoDB query operators.", prisma: - "An ORM client, db.orm.. On MongoDB, a pipeline builder for aggregation, and raw commands for anything the two APIs do not cover.", + "An ORM client, `db.orm.`, addressed by the storage name (`db.orm.posts`, not `db.orm.Post`). A pipeline builder for aggregation, and raw commands for anything the two APIs do not cover.", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/docs/orm/reference", + }, }, { - feature: "Related documents", + feature: "Embedded documents", + mongoose: "Nested schemas and subdocuments on the parent document.", + prisma: + "A `type` block in the contract, used as a field. Embedded values have no `_id` and no collection of their own.", + sources: { + mongoose: "https://mongoosejs.com/docs/subdocs.html", + prisma: "https://www.prisma.io/docs/orm/data-modeling/mongodb", + }, + }, + { + feature: "Polymorphic collections", mongoose: - "populate() loads related documents in a separate query. match filters which of those documents are attached, and the parent stays in the result. populate() cannot filter parents by a field on the related document.", + "`Model.discriminator()` stores variants in one collection. Variant fields are optional or split across schemas you keep in application code.", + prisma: + "`@@discriminator` on the base model and `@@base` on each variant. `db.orm.posts.variant('article')` narrows the TypeScript type. Query all posts and you get the union.", + sources: { + mongoose: "https://mongoosejs.com/docs/discriminators.html", + prisma: "https://www.prisma.io/docs/orm/data-modeling/mongodb", + }, + }, + { + feature: "Aggregation", + mongoose: "`Model.aggregate()` takes a pipeline array. Stages are not type-checked.", prisma: - "On MongoDB, include() loads a reference declared with @relation, and it takes the relation name only. An array of ids stored on the parent has no include() equivalent yet. Relation filters are not available on MongoDB. On PostgreSQL, where can use some, none, and every.", + "The pipeline builder at `db.query` types field names and each stage's output. Raw commands cover stages the builder does not.", + sources: { + mongoose: "https://mongoosejs.com/docs/api/aggregate.html", + prisma: "https://www.prisma.io/docs/orm/reference/pipeline-builder", + }, }, { - feature: "Pagination", + feature: "Related documents", mongoose: - "skip and limit on queries. Pair skip with sort: without a sort, MongoDB does not promise the order skip counts from.", + "`populate()` loads related documents in a separate query. `match` filters which of those documents are attached, and the parent stays in the result. `populate()` cannot filter parents by a field on the related document.", prisma: - "limit and offset on MongoDB and on PostgreSQL. cursor follows orderBy and is PostgreSQL only. On MongoDB, page with limit and offset.", + "On MongoDB, `include()` loads a reference declared with `@relation`, and it takes the relation name only. An array of ids stored on the parent has no `include()` equivalent yet; the migrate guide uses `lookup` in the pipeline builder. Relation filters are not available on MongoDB. On PostgreSQL, `where` can use `some`, `none`, and `every`.", + sources: { + mongoose: "https://mongoosejs.com/docs/populate.html", + prisma: "https://www.prisma.io/docs/guides/switch-to-prisma-orm/from-mongoose", + }, }, { feature: "Middleware", mongoose: "pre and post hooks. Four kinds: document, model, aggregate, and query middleware.", prisma: - "Middleware runs on MongoDB, except beforeCompile, which exists on PostgreSQL. The cache middleware runs on both databases.", + "Middleware runs on MongoDB, except `beforeCompile`, which exists on PostgreSQL. The cache middleware runs on both databases.", + sources: { + mongoose: "https://mongoosejs.com/docs/middleware.html", + prisma: "https://www.prisma.io/docs/orm/supported-databases", + }, }, { feature: "Migrations", mongoose: "The getting started guide has no migration command.", prisma: - "migration plan writes migration.ts from the contract. Planning does not connect to a database. db migrate runs ops.json, not the TypeScript file.", + "`migration plan` writes `migration.ts` from the contract. Planning does not connect to a database. `db migrate` runs `ops.json`. On MongoDB that file can create collections, indexes, and JSON Schema validators, and it can run a `dataTransform`.", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/docs/orm/reference/migration-api", + }, }, { feature: "Databases", mongoose: "MongoDB. The README describes Mongoose as a MongoDB object modeling tool.", prisma: - "PostgreSQL is a release candidate. MongoDB is early access. SQLite is experimental. MySQL, MariaDB, SQL Server, and CockroachDB stay on Prisma ORM 7.", + "PostgreSQL is a release candidate. MongoDB is early access. SQLite is experimental. MySQL, MariaDB, SQL Server, and CockroachDB stay on Prisma ORM 7. See [database support](#databases).", + sources: { + mongoose: "https://github.com/Automattic/mongoose/blob/master/README.md", + prisma: "https://www.prisma.io/docs/orm/supported-databases", + }, }, { feature: "Hosted database", mongoose: "None. Mongoose is a library. You bring MongoDB.", prisma: - "[Prisma Postgres](/docs/postgres) is a separate product. Any Postgres client works with it, so Prisma ORM is optional. [Prisma Compute](/docs/compute) is separate app hosting. Mongoose connects to MongoDB, so it does not connect to Prisma Postgres.", + "[Prisma Postgres](/docs/postgres) is a separate product. Any Postgres client works with it, so Prisma ORM is optional. Mongoose connects to MongoDB, so it does not connect to Prisma Postgres. [Prisma Compute](/docs/compute) is separate app hosting.", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/docs/postgres", + }, }, ]; -export const MONGOOSE_FITS = [ - "Schemas, document methods, and validators live in the application, next to the model.", - "populate() should load a referenced document, and match should narrow which related documents come back.", - "pre and post hooks should run around save, find, and aggregate.", - "The database is MongoDB, and find() should take MongoDB query operators.", +export type DatabaseStatus = + | "Supported" + | "Release candidate" + | "Early access" + | "Experimental" + | "ORM 7 only" + | "Use 6.19" + | null; + +export const FULL_SUPPORT: readonly DatabaseStatus[] = ["Supported", "Release candidate"]; + +export const DATABASE_SOURCES = { + mongoose: "https://github.com/Automattic/mongoose/blob/master/README.md", + orm8: "https://www.prisma.io/docs/orm/supported-databases", + orm7: "https://www.prisma.io/docs/orm/v7/reference/supported-databases", + orm7Mongo: "https://www.prisma.io/docs/guides/v7/switch-to-prisma-orm/from-mongoose", +} as const; + +export const DATABASE_MATRIX: readonly { + database: string; + mongoose: DatabaseStatus; + orm8: DatabaseStatus; + orm7: DatabaseStatus; +}[] = [ + { database: "MongoDB", mongoose: "Supported", orm8: "Early access", orm7: "Use 6.19" }, + { database: "PostgreSQL", mongoose: null, orm8: "Release candidate", orm7: "Supported" }, + { database: "MySQL", mongoose: null, orm8: "ORM 7 only", orm7: "Supported" }, + { database: "MariaDB", mongoose: null, orm8: "ORM 7 only", orm7: "Supported" }, + { database: "Microsoft SQL Server", mongoose: null, orm8: "ORM 7 only", orm7: "Supported" }, + { database: "SQLite", mongoose: null, orm8: "Experimental", orm7: "Supported" }, + { database: "CockroachDB", mongoose: null, orm8: "ORM 7 only", orm7: "Supported" }, ]; -export const PRISMA_FITS = [ - "One contract should describe the collections or the tables, and contract emit should write the types.", - "The application also ships on PostgreSQL, where the release candidate includes the ORM client, a SQL query builder, and relation filters.", - "migration plan should write the migration from the contract, including on MongoDB.", - "A referenced document declared with @relation should load with include().", +export const PRICING: readonly { + label: string; + mongoose: string; + prisma: string; + sources: { mongoose: string; prisma: string }; +}[] = [ + { + label: "Library license and cost", + mongoose: "Free, MIT", + prisma: "Free, Apache 2.0", + sources: { + mongoose: "https://github.com/Automattic/mongoose/blob/master/LICENSE.md", + prisma: "https://github.com/prisma/prisma/blob/main/LICENSE", + }, + }, + { + label: "Paid plans", + mongoose: "None", + prisma: "None for the ORM", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/docs/orm", + }, + }, + { + label: "Hosted database", + mongoose: "None. You bring MongoDB.", + prisma: + "[Prisma Postgres](/docs/postgres), optional. It is PostgreSQL, so Mongoose does not connect to it.", + sources: { + mongoose: "https://mongoosejs.com/docs/", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + +export const SAME_JOB: readonly { task: string; mongoose: string; prisma: string }[] = [ + { + task: "Define collections", + mongoose: "A `Schema` compiled with `mongoose.model`.", + prisma: "Models in `contract.prisma`, with `@@map` for the collection name.", + }, + { + task: "Generate types", + mongoose: "TypeScript types you write, or types inferred from the schema in application code.", + prisma: "`contract emit` writes the types from the contract.", + }, + { + task: "Read an existing database", + mongoose: "The schemas in the app are the source of truth. MongoDB has no schema to infer.", + prisma: + "`contract infer` does not read MongoDB. You write `contract.prisma` from the Mongoose schemas.", + }, + { + task: "Write a migration", + mongoose: "The getting started guide has no migration command. You change documents in code.", + prisma: + "`migration plan` writes `migration.ts`. On MongoDB that can include `createIndex`, `setValidation`, and `dataTransform`.", + }, + { + task: "Run an aggregation", + mongoose: "`Model.aggregate([...])` with untyped pipeline stages.", + prisma: "`db.query.from('')`, then `runtime.query(...)` or `runtime.execute(...)`.", + }, + { + task: "Query a polymorphic collection", + mongoose: "`Model.discriminator()` and query the discriminator model.", + prisma: + "`@@discriminator` / `@@base` in the contract, then `.variant('article')` on the base collection.", + }, + { + task: "Load a related document", + mongoose: "`populate()` on a stored id. `match` can narrow which related documents come back.", + prisma: + "`include()` on a single `@relation`. An array of ids has no `include()` equivalent yet; use `lookup` in the pipeline builder.", + }, + { + task: "Run middleware", + mongoose: "pre and post hooks on documents, queries, aggregates, and models.", + prisma: "Middleware on MongoDB, except `beforeCompile`. The cache middleware runs on MongoDB.", + }, + { + task: "Apply schema changes", + mongoose: "You update documents yourself. There is no Mongoose migrate command in the guide.", + prisma: "`db migrate` applies the pending migrations on disk.", + }, ]; export const APART = [ { - title: "A contract for PostgreSQL and MongoDB", - body: "Models live in contract.prisma. PostgreSQL, including Prisma Postgres, is the release-candidate database. MongoDB is an early-access library, @prisma/orm-mongo. SQLite is experimental.", + id: "typed-pipeline", + title: "A typed aggregation pipeline", + body: `Mongoose's \`aggregate()\` takes a pipeline array. Prisma ORM 8's MongoDB library has a pipeline builder that types field names and the shape after each stage, then you drop to raw commands when the builder does not cover a stage. [MongoDB without compromise](${PRISMA_MONGODB_BLOG}) is the Prisma write-up of that stack, checked against the [pipeline builder](/docs/orm/reference/pipeline-builder).`, }, { - title: "Types from the contract", - body: "contract emit writes the types your queries use. Prisma ORM 8 does not use the @prisma/client package. That package is Prisma ORM 7. On MongoDB the field you query is the stored name, so a field mapped to _id is _id in TypeScript.", + id: "polymorphic-collections", + title: "Polymorphic collections from the contract", + body: `\`@@discriminator\` and \`@@base\` declare variants in one collection, and \`.variant('article')\` narrows the TypeScript union. Mongoose has [\`discriminator()\`](https://mongoosejs.com/docs/discriminators.html) too; the Prisma side generates the unions from the contract. MongoDB's [Prisma Next post](${MONGO_BLOG_PRISMA_NEXT}) says their Driver team named this as the top missing feature in earlier Prisma ORM. The [MongoDB data modeling](/docs/orm/data-modeling/mongodb) docs are the current syntax.`, }, { - title: "Migrations from the contract", - body: "migration plan reads the contract and writes migration.ts. Planning does not connect to a database. On MongoDB the quickstart applies that plan with db migrate, which runs ops.json.", + id: "mongo-migrations", + title: "Migrations for indexes and validators", + body: `\`migration plan\` writes \`migration.ts\` without connecting to the database. On MongoDB, \`db migrate\` can create collections, indexes, and JSON Schema validators, and it can run a \`dataTransform\`. The April 2026 [MongoDB without compromise](${PRISMA_MONGODB_BLOG}) post said data migrations were still coming; the [migration API](/docs/orm/reference/migration-api) now documents \`dataTransform\` on MongoDB.`, }, { - title: "Relation filters on PostgreSQL", - body: "On PostgreSQL, where can select parent rows with some, none, and every. On MongoDB those filters are absent. You query the child collection and include the parent.", + id: "postgres-too", + title: "The same ORM client on PostgreSQL", + body: "Mongoose speaks MongoDB. Prisma ORM 8's PostgreSQL library is a release candidate: the ORM client, a SQL query builder, and relation filters (`some`, `none`, `every`) that the MongoDB client does not have.", }, ]; export const WRONG_CHOICE = [ - "You want document and query middleware, and multi-document transactions, from a MongoDB library that is already a stable release. Prisma ORM 8's MongoDB library is early access. db.transaction() is absent on the MongoDB client. Multi-document writes go through the MongoDB driver. See [transactions](/docs/orm/fundamentals/transactions).", - "You store arrays of related ids and load them with populate(). The [migrate from Mongoose](/docs/guides/switch-to-prisma-orm/from-mongoose) guide says that shape has no include() equivalent yet, and shows lookup in the pipeline builder instead.", + "You want document and query middleware, and `populate()` on arrays of stored ids, from a MongoDB library that is already a stable release. Prisma ORM 8's MongoDB library is early access. An array of ids has no `include()` equivalent yet. See [migrate from Mongoose](/docs/guides/switch-to-prisma-orm/from-mongoose).", + "You want multi-document transactions on MongoDB. `db.transaction()` is absent on the MongoDB client. Multi-document writes go through the MongoDB driver. See [supported databases](/docs/orm/supported-databases).", "You need MySQL, MariaDB, SQL Server, or CockroachDB from the current Prisma ORM 8 packages. Those databases are absent from ORM 8. [Prisma ORM 7](/docs/orm/v7) still supports them. Mongoose speaks MongoDB. See [supported databases](/docs/orm/supported-databases).", - "You want cursor pagination on MongoDB. cursor is PostgreSQL only. On MongoDB, page with limit and offset. See [reading data](/docs/orm/fundamentals/reading-data).", ]; export const FAQ: readonly { question: string; answer: string }[] = [ { - question: "Is Mongoose an ORM?", - answer: - "Mongoose is a MongoDB object modeling tool. The getting started guide builds a Schema, compiles it with mongoose.model, and queries with find(). It does not generate a client from a separate contract file. Source: [Mongoose README](https://github.com/Automattic/mongoose/blob/master/README.md) and [getting started](https://mongoosejs.com/docs/), October 2026.", + question: "Is Prisma Next a different product from Prisma ORM 8?", + answer: `No. MongoDB's May 2026 post [Prisma Next: The TypeScript ODM You Always Wanted](${MONGO_BLOG_PRISMA_NEXT}) uses that name for the TypeScript ODM built with Prisma. On prisma.io the same work is Prisma ORM 8's MongoDB library, \`@prisma/orm-mongo\`, which is early access. \`npm install prisma\` installs Prisma ORM 8. The post's \`npx prisma-next init\` command is not the current install path; the docs use \`npx prisma@latest orm init --target mongodb\`. Source: that MongoDB post, [MongoDB without compromise](${PRISMA_MONGODB_BLOG}), and [supported databases](/docs/orm/supported-databases), October 2026.`, }, { - question: "Is Prisma ORM 8 a final release?", - answer: - "No. Prisma ORM 8 is a release candidate, and it is what npm install prisma gives you today. PostgreSQL is the release-candidate database. MongoDB is early access, and SQLite is experimental. Source: [Prisma ORM](/docs/orm) and [supported databases](/docs/orm/supported-databases), October 2026.", + question: "Does Prisma ORM work with MongoDB?", + answer: `Yes. The [MongoDB product page](${PRISMA_MONGODB_PAGE}) describes Prisma's schema and a type-safe client for MongoDB, including filters, pagination, ordering, embedded documents, and references. Its query samples use Prisma Client's \`findMany\` API from earlier Prisma ORM lines. Prisma ORM 8 queries go through \`db.orm.\`. MongoDB on Prisma ORM 8 is early access: the ORM client, pipeline builder, and migrations work; \`db.transaction()\` does not. Source: [MongoDB product page](${PRISMA_MONGODB_PAGE}), [supported databases](/docs/orm/supported-databases), October 2026.`, }, { question: "Can Prisma ORM and Mongoose use the same MongoDB database?", answer: - "Yes. The migrate guide adds Prisma ORM beside Mongoose and reads the same collections. You write contract.prisma for the collections you query through Prisma ORM. contract infer does not read MongoDB. The documents stay where they are. Source: [migrate from Mongoose](/docs/guides/switch-to-prisma-orm/from-mongoose), October 2026.", + "Yes. The migrate guide adds Prisma ORM beside Mongoose and reads the same collections. You write `contract.prisma` for the collections you query through Prisma ORM. `contract infer` does not read MongoDB. The documents stay where they are. Source: [migrate from Mongoose](/docs/guides/switch-to-prisma-orm/from-mongoose), October 2026.", }, { question: "Does Mongoose work with Prisma Postgres?", @@ -155,3 +379,67 @@ export const FAQ: readonly { question: string; answer: string }[] = [ "Mongoose connects to MongoDB. Prisma Postgres is PostgreSQL, and Prisma ORM is optional with it: a Postgres client uses the connection string. Mongoose is not a Postgres client. Source: [Mongoose getting started](https://mongoosejs.com/docs/) and [Prisma Postgres](/docs/postgres), October 2026.", }, ]; + +/** Representative Mongoose models for the User and Post collections in the same-query example. */ +export const SWITCH_MONGOOSE_SCHEMA = `import { Schema, model } from "mongoose"; + +const userSchema = new Schema({ + email: { type: String, required: true, unique: true }, + name: { type: String }, +}); + +export const User = model("User", userSchema); + +const postSchema = new Schema({ + title: { type: String, required: true }, + published: { type: Boolean, default: false }, + author: { type: Schema.Types.ObjectId, ref: "User", required: true }, +}); + +export const Post = model("Post", postSchema);`; + +/** + * Representative `contract.prisma` matching those collections, in the shape + * the migrate-from-Mongoose guide writes (`// use prisma-8`, ObjectId, @@map). + */ +export const SWITCH_PRISMA_CONTRACT = `// use prisma-8 + +model User { + id ObjectId @id @map("_id") + email String @unique + name String? + posts Post[] + @@map("users") +} + +model Post { + id ObjectId @id @map("_id") + title String + published Bool + author User @relation(fields: [authorId], references: [id]) + authorId ObjectId @map("author") + @@map("posts") +}`; + +/** Commands from the Prisma ORM 8 migrate-from-Mongoose guide. */ +export const SWITCH_COMMANDS = `npx prisma@latest orm init --target mongodb +npx prisma contract emit`; + +export const TOOLS = { + mongoose: { + name: "Mongoose", + softwareVersion: "9.11.2", + license: "https://opensource.org/licenses/MIT", + codeRepository: "https://github.com/Automattic/mongoose", + programmingLanguage: "TypeScript", + url: "https://mongoosejs.com", + }, + prisma: { + name: "Prisma ORM", + softwareVersion: "8", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/orm", + }, +} as const; diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index d1c9787ab52..3a3996f74a6 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -1,16 +1,35 @@ import Link from "next/link"; +import { + CodeBlock, + CodeBlockTab, + CodeBlockTabs, + CodeBlockTabsList, + CodeBlockTabsTrigger, +} from "@prisma/eclipse"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { CheckBold } from "@/components/icons/forma"; -import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; -import { RoleKicker } from "@/components/brand/role-kicker"; -import { Texture } from "@/components/brand/texture"; -import { CtaBurst } from "@/components/sections/cta-burst"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK, QUIET_NOTE } from "../table-styles"; +import { highlight, lineGroupMap } from "../kysely/highlight"; +import { SameQueryHighlight } from "../kysely/same-query"; +import { TabCodeCopy } from "../tab-code-copy"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -19,485 +38,556 @@ import { CHOOSE_PRISMA, COMPARE_ROWS, FAQ, - MONGOOSE_FITS, + MONGOOSE_AUTHOR, MONGOOSE_TITLE, - PRISMA_FITS, + MONGOOSE_UPDATED, + MONGO_BLOG_PRISMA_NEXT, + PRISMA_MONGODB_BLOG, + PRISMA_MONGODB_PAGE, + PRICING, + SAME_QUERY, + SAME_QUERY_LINE_GROUPS, + SWITCH_COMMANDS, + SWITCH_MONGOOSE_SCHEMA, + SWITCH_PRISMA_CONTRACT, WRONG_CHOICE, + DATABASE_MATRIX, + type DatabaseStatus, + FULL_SUPPORT, + SAME_JOB, } from "./_content"; const INLINE = - "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-black/60"; + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; + +/** + * Full support reads as body text; preview and ORM 7 labels are muted; No is + * no support. Called as a function so the Markdown walker sees the elements. + */ +function databaseStatus(status: DatabaseStatus) { + if (status === null) { + return No; + } + return ( + + {status} + + ); +} + +/** + * The site does not build the eclipse tokens these components use + * (`rounded-square`, `border-stroke-neutral`, `type-text-sm-strong`, the active + * tab bar), so the same values are set with site utilities. + */ +const CODE_TABS = "my-0 overflow-hidden rounded-[0.625rem] border border-border bg-white"; +const CODE_TAB_LIST = "flex min-w-0 flex-1 flex-row overflow-x-auto px-2"; +const CODE_TAB = + "relative inline-flex min-h-11 items-center px-2 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-[state=active]:text-primary data-[state=active]:[&>div]:bg-primary"; +const CODE_BLOCK = "not-fumadocs-codeblock my-0 rounded-none border-0 bg-white [&>div]:py-3.5"; +const CODE_BLOCK_WITH_COPY = `${CODE_BLOCK} rounded-[0.625rem] border border-border [&>[role=region]]:mr-11 [&_button]:relative [&_button]:text-muted-foreground [&_button]:after:absolute [&_button]:after:-inset-2.5 [&_button]:after:content-['']`; const PROOF = TESTIMONIALS.filter( (item) => item.name === "Ron Cohen" || item.name === "Omar López", ); +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +const SOURCES = [ + { + product: "Mongoose", + links: [ + { label: "Getting started", href: "https://mongoosejs.com/docs/" }, + { label: "mongoose on npm", href: "https://www.npmjs.com/package/mongoose" }, + { label: "Populate", href: "https://mongoosejs.com/docs/populate.html" }, + { label: "Discriminators", href: "https://mongoosejs.com/docs/discriminators.html" }, + { label: "Middleware", href: "https://mongoosejs.com/docs/middleware.html" }, + { label: "README", href: "https://github.com/Automattic/mongoose/blob/master/README.md" }, + { + label: "MIT license", + href: "https://github.com/Automattic/mongoose/blob/master/LICENSE.md", + }, + ], + }, + { + product: "Prisma ORM and Prisma Postgres", + links: [ + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Supported databases", href: "/docs/orm/supported-databases" }, + { + label: "Prisma ORM 7 supported databases", + href: "/docs/orm/v7/reference/supported-databases", + }, + { + label: "Migrate from Mongoose", + href: "/docs/guides/switch-to-prisma-orm/from-mongoose", + }, + { + label: "Prisma ORM 7 migrate from Mongoose", + href: "/docs/guides/v7/switch-to-prisma-orm/from-mongoose", + }, + { label: "contract emit", href: "/docs/cli/contract-emit" }, + { label: "migration plan", href: "/docs/cli/migration-plan" }, + { label: "db migrate", href: "/docs/cli/db-migrate" }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + { label: "Prisma + MongoDB", href: PRISMA_MONGODB_PAGE }, + { label: "MongoDB without compromise", href: PRISMA_MONGODB_BLOG }, + { + label: "Prisma Next: The TypeScript ODM You Always Wanted", + href: MONGO_BLOG_PRISMA_NEXT, + }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + /** Build the shared content tree for the HTML page and its Markdown rendition. */ -export function getMongoosePageContent() { - return ( - <> -
-
-
-
-
-
- -
-
- Compare -

- {MONGOOSE_TITLE} -

-

- Pick{" "} - - Prisma ORM - {" "} - when a contract file should drive typed queries and migrations, on PostgreSQL or on - MongoDB. Pick{" "} - +function buildMongoosePage(minutes: number) { + const mongooseGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "mongoose"); + const prismaGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "prisma"); + const mongoGroups = lineGroupMap(SAME_QUERY_LINE_GROUPS, "mongo"); + + return [ +

+

+ {MONGOOSE_TITLE} +

+

+ Both give you typed MongoDB access in TypeScript, from opposite ends.{" "} + + Prisma ORM + {" "} + starts from a contract file: you describe your collections once, and typed queries, + aggregations, and migrations follow from it.{" "} + + Mongoose + {" "} + starts from a Schema in application code: you compile it with mongoose.model{" "} + and query with find() and populate(). +

+ {comparisonHeroCtas("orm")} + {comparisonByline({ author: MONGOOSE_AUTHOR, updated: MONGOOSE_UPDATED, minutes })} + {comparisonDisclosure({ competitor: "Mongoose", checked: CHECKED })} +
, +
+
+ + When should I choose Mongoose or Prisma ORM? + + {comparisonChooseTable({ + competitor: "Mongoose", + prisma: "Prisma ORM", + competitorItems: CHOOSE_MONGOOSE, + prismaItems: CHOOSE_PRISMA, + })} +

+ MongoDB's May 2026 post{" "} + + Prisma Next: The TypeScript ODM You Always Wanted + {" "} + is their write-up of this work: typed documents, a typed aggregation pipeline, and + migrations for indexes instead of a deploy script. On prisma.io the same library is Prisma + ORM 8's MongoDB early access, not a separate product named Prisma Next. +

+
+ +
+ + What does the same query look like in each? + +

+ Each snippet reads published posts with their authors: Mongoose attaches the authors with{" "} + populate("author"), and Prisma ORM loads them with{" "} + include("author"). The MongoDB tab is representative of the + commands both send, not captured from a live run. +

+ + +
+ + Mongoose - {" "} - when the app runs on MongoDB and the schema, populate, and middleware should live in - application code. Prisma makes Prisma ORM. Mongoose is an independent open-source - project. The other ORM comparison is{" "} - - Prisma ORM vs Kysely - - , and all{" "} - - Prisma comparisons - {" "} - are listed in one place. -

-

- Every competitor figure on this page has a source, listed under the table and - checked {CHECKED}. -

-
- - Read the ORM docs - - - See which fits - -
+
+ + Prisma ORM + + + MongoDB sent + +
+
-
-
+ + + {highlight(SAME_QUERY.mongoose, "typescript", mongooseGroups)} + + + + + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} + + + + + {highlight(SAME_QUERY.mongo, "typescript", mongoGroups)} + + + +

+ + + author include + + + + published filter + + Tinted lines match across the open tab. Hover one to emphasize its pair. +

+
-
-
-
-

- Choose Prisma ORM if -

-
    - {CHOOSE_PRISMA.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
-

- Choose Mongoose if -

-
    - {CHOOSE_MONGOOSE.map((item) => ( -
  • - - {renderFaqAnswer(item)} -
  • - ))} -
-
-
+
+ + How do Prisma ORM and Mongoose compare feature by feature? + +

+ Mongoose models MongoDB documents in application code. Prisma ORM is a contract-based ORM + with a typed pipeline, polymorphic collections, and MongoDB migrations for indexes and + validators. On MongoDB the PostgreSQL APIs (SQL query builder, relation filters) are + absent. Columns are in alphabetical order. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.mongoose), renderFaqAnswer(row.prisma)], + }))} + />
-
-
-

+ + Which databases do Prisma ORM and Mongoose support? + +

+ Mongoose's MongoDB support next to the two Prisma ORM lines. Prisma ORM 7 is the + generally available release. No means no support. The{" "} + + MongoDB product page + {" "} + describes Prisma's schema and type-safe client for MongoDB, including embedded + documents and references. Its query samples use Prisma Client's findMany{" "} + API from earlier Prisma ORM lines. Prisma ORM 8 queries go through{" "} + db.orm.<collection>. +

+ ({ + label: row.database, + cells: [ + databaseStatus(row.mongoose), + databaseStatus(row.orm8), + databaseStatus(row.orm7), + ], + }))} + /> +

+ MongoDB on Prisma ORM 7 is listed as supported on the ORM 7 databases page. The ORM 7{" "} + + migrate-from-Mongoose guide + {" "} + says Prisma ORM 7 has no MongoDB connector yet, and to stay on 6.19 until it ships. This + table follows that guide for MongoDB. Sources:{" "} + - What each one supports -

-

- Columns are alphabetical: Mongoose, then Prisma ORM. One is a MongoDB object modeling - library. The other is a contract-based ORM with a PostgreSQL release candidate and an - early-access MongoDB library. -

-
+ +
+ + {"What does Prisma ORM do that Mongoose doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

-
-

- Mongoose cells:{" "} - - Getting started - {" "} - (v9.11.2),{" "} - - populate - - ,{" "} - - middleware - - , the{" "} - - README - - , and the{" "} - - MIT license - - , {CHECKED}. Prisma ORM cells:{" "} - - Prisma ORM - - ,{" "} - - supported databases - - ,{" "} - - relations - - ,{" "} - - reading data - - ,{" "} - - migrate from Mongoose - - , and the{" "} - - Apache License 2.0 - - , {CHECKED}. Hosting is on the{" "} - - pricing page - - . -

+ ))}
-
-
-
-

- Where Mongoose works well -

-

- Mongoose is a MongoDB object modeling library. Its{" "} - - getting started guide - {" "} - builds a schema, compiles a model, and queries with{" "} - find().{" "} - - populate() - {" "} - loads documents from another collection. -

-
    - {MONGOOSE_FITS.map((item) => ( -
  • - {item} -
  • - ))} -
-
-
-

- Where Prisma ORM fits better -

-

- Prisma ORM fits a team that wants the contract, the model queries, and the migrations - to come from one file. The{" "} - - ORM docs +

+ + How much do Prisma ORM and Mongoose cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.mongoose), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. The{" "} + + pricing page {" "} - describe that file as the contract. MongoDB support is early access, and the limits - are in the table above. -

-
    - {PRISMA_FITS.map((item) => ( -
  • - {item} -
  • - ))} -
-
-
+ lists the paid plans. + + } + />
-
-
-

- What sets Prisma ORM apart -

-
- {APART.map((item) => ( -
-

{item.title}

-

- {item.body} -

-
- ))} -
-
+
+ + When is Prisma ORM the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
-
-
-

- Pricing -

-
-
-

Mongoose

-

- The library is free under the{" "} - - MIT license - - . There is no Mongoose hosting plan. You bring MongoDB. Checked {CHECKED}. -

-
-
-

Prisma ORM and Prisma Postgres

-

- Prisma ORM is free under the{" "} - - Apache License 2.0 - - . Prisma Postgres is optional, and Prisma ORM is optional with it. The Free plan is - $0, with 200,000 operations a month, 2 GB of storage, and 50 databases ( - - pricing page - - , {CHECKED}). Prisma Compute is separate app hosting. -

-
-
-
+
+ + How does each task look in Mongoose and Prisma ORM? + +

+ What each step of the usual workflow looks like on each side, for a team moving in either + direction. +

+ ({ + label: row.task, + cells: [renderFaqAnswer(row.mongoose), renderFaqAnswer(row.prisma)], + }))} + />
-
-
-

- When Prisma ORM is the wrong choice -

-
    - {WRONG_CHOICE.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
-
+
+ + How do I switch from Mongoose to Prisma ORM? + +

+ The{" "} + + migrate-from-Mongoose guide + {" "} + adds Prisma ORM beside the collections you already have.{" "} + orm init --target mongodb writes the Prisma files.{" "} + contract infer does not read a MongoDB database, so you write{" "} + contract.prisma from the Mongoose schemas, then contract emit{" "} + writes the types. The documents stay where they are. +

+

+ Representative models for the User and Post collections in the query above, then the two + commands. +

+ +
+ + + Mongoose schema + + + contract.prisma + + + +
+ + + {highlight(SWITCH_MONGOOSE_SCHEMA)} + + + + + {highlight(SWITCH_PRISMA_CONTRACT, "prisma")} + + +
+ {highlight(SWITCH_COMMANDS, "bash")} +
    +
  1. + Later changes go through{" "} + + contract emit and migration plan + + . On MongoDB you still write the contract by hand. +
  2. +
-
-
-
-

- Move from Mongoose to Prisma ORM -

-
    -
  1. - Keep the MongoDB database. The{" "} - - migrate from Mongoose - {" "} - guide adds Prisma ORM beside the existing models. The documents stay put. -
  2. -
  3. - Run{" "} - npx prisma@latest orm init --target mongodb - . Write contract.prisma yourself.{" "} - contract infer does not read MongoDB. -
  4. -
  5. - Run npx prisma contract emit. Replace a - single-reference populate() with{" "} - include(). For an array of stored ids, the - guide uses lookup in the pipeline builder. -
  6. -
-
-
-

- Move from Prisma ORM to Mongoose -

-
    -
  1. Keep the MongoDB database and the connection string.
  2. -
  3. - Define a mongoose.Schema and compile it - with mongoose.model for each collection you - still query. The{" "} - - getting started guide - {" "} - shows that sequence. -
  4. -
  5. - Connect with mongoose.connect and the same - MongoDB URL. Mongoose does not connect to Prisma Postgres. -
  6. -
-
-
+
+ + How do I switch from Prisma ORM to Mongoose? + +
    +
  1. Keep the MongoDB database and the connection string.
  2. +
  3. + Define a Schema and compile it with mongoose.model for each + collection. The{" "} + + getting started guide + {" "} + is that path. +
  4. +
  5. + Use populate() on stored ids. Prisma ORM's include() on + MongoDB covers a single @relation; an array of ids uses lookup{" "} + in the pipeline builder. +
  6. +
-
-
-

- What teams on Prisma say -

-

- These two quotes are on prisma.io today, from teams using Prisma ORM for type safety. -

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-
+
+ + What do teams say about Prisma ORM? + +

Two teams on what type safety in Prisma ORM does for them.

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )}
-
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("orm"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • ))} - +
-
-
+ ))} +
+
, + ]; +} - - - ); +const MONGOOSE_READING = articleReadingStats(buildMongoosePage(1)); + +export function getMongooseReadingStats() { + return articleReadingStats(buildMongoosePage(MONGOOSE_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getMongoosePageContent() { + return buildMongoosePage(MONGOOSE_READING.minutes); } -/** Render the comparison using its shared content tree. */ +/** Render the comparison in the guide article frame. */ export function MongoosePage() { - return getMongoosePageContent(); + return {getMongoosePageContent()}; } diff --git a/apps/site/src/app/compare/mongoose/facts.ts b/apps/site/src/app/compare/mongoose/facts.ts new file mode 100644 index 00000000000..792e3850d7c --- /dev/null +++ b/apps/site/src/app/compare/mongoose/facts.ts @@ -0,0 +1,110 @@ +import { + COMPARE_ROWS, + DATABASE_MATRIX, + DATABASE_SOURCES, + MONGOOSE_PATH, + MONGOOSE_PUBLISHED, + MONGOOSE_UPDATED, + PRICING, + TOOLS, +} from "./_content"; +import { getMongooseReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +function statusValue(status: string | null): string { + return status ?? "No"; +} + +/** Machine-readable feature, database, and pricing cells with a source on every value. */ +export function getMongooseFacts() { + const dateChecked = MONGOOSE_UPDATED.iso; + return { + path: MONGOOSE_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + mongoose: { + value: row.mongoose, + source: row.sources.mongoose, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + databases: DATABASE_MATRIX.map((row) => ({ + database: row.database, + mongoose: { + value: statusValue(row.mongoose), + source: DATABASE_SOURCES.mongoose, + dateChecked, + } satisfies FactValue, + orm8: { + value: statusValue(row.orm8), + source: DATABASE_SOURCES.orm8, + dateChecked, + } satisfies FactValue, + orm7: { + value: statusValue(row.orm7), + source: row.database === "MongoDB" ? DATABASE_SOURCES.orm7Mongo : DATABASE_SOURCES.orm7, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + mongoose: { + value: row.mongoose, + source: row.sources.mongoose, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getMongooseAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.mongoose.name, + softwareVersion: TOOLS.mongoose.softwareVersion, + license: TOOLS.mongoose.license, + codeRepository: TOOLS.mongoose.codeRepository, + programmingLanguage: TOOLS.mongoose.programmingLanguage, + url: TOOLS.mongoose.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getMongooseArticleFields() { + const { wordCount, timeRequired } = getMongooseReadingStats(); + return { + datePublished: MONGOOSE_PUBLISHED.iso, + dateModified: MONGOOSE_UPDATED.iso, + wordCount, + timeRequired, + about: getMongooseAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/mongoose/page.tsx b/apps/site/src/app/compare/mongoose/page.tsx index a0f2ba92fff..faae1401c4a 100644 --- a/apps/site/src/app/compare/mongoose/page.tsx +++ b/apps/site/src/app/compare/mongoose/page.tsx @@ -1,20 +1,59 @@ +import localFont from "next/font/local"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; -import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; -import { FAQ, MONGOOSE_PATH, MONGOOSE_TITLE } from "./_content"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { FAQ, MONGOOSE_AUTHOR, MONGOOSE_PATH, MONGOOSE_TITLE } from "./_content"; +import { getMongooseArticleFields } from "./facts"; import { MongoosePage } from "./_sections"; +/** + * The docs' code face. Shared with the Kysely comparison so both pages load + * one copy of the unbracketed Mona Sans Mono file. + */ +const monaSansMono = localFont({ + src: "../kysely/mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + export const metadata = getComparisonMetadata("mongoose"); const faqStructuredData = createFaqStructuredData(MONGOOSE_PATH, [...FAQ], MONGOOSE_TITLE); +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("mongoose"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getMongooseArticleFields(), + image: getComparisonOgImageUrl("mongoose"), + author: comparisonAuthorPerson(MONGOOSE_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + /** Render the comparison and its article, breadcrumb and FAQ structured data. */ export default function Page() { return ( <> - + - +
+ +
); } diff --git a/apps/site/src/app/compare/neon.json/route.ts b/apps/site/src/app/compare/neon.json/route.ts new file mode 100644 index 00000000000..df1f6d0f089 --- /dev/null +++ b/apps/site/src/app/compare/neon.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getNeonFacts } from "../neon/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getNeonFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/neon/_content.ts b/apps/site/src/app/compare/neon/_content.ts new file mode 100644 index 00000000000..1c7f1f7e7bc --- /dev/null +++ b/apps/site/src/app/compare/neon/_content.ts @@ -0,0 +1,250 @@ +export const NEON_PATH = "/compare/neon"; + +export const NEON_TITLE = + "Prisma Postgres vs Neon: TypeScript app hosting or Neon backend primitives?"; + +export const NEON_DESCRIPTION = + "Both are managed PostgreSQL. Pick Prisma Postgres for optional Prisma ORM and Prisma Compute in one project. Pick Neon for Functions, Auth, Object Storage, and AI Gateway. Checked October 2026."; + +export const CHECKED = "October 2026"; + +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const NEON_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const NEON_PUBLISHED = { iso: "2026-10-19", label: "October 2026" } as const; +export const NEON_UPDATED = { iso: "2026-10-19", label: "October 2026" } as const; + +export const CHOOSE_PRISMA = [ + "You want [Prisma ORM](/docs/orm) available on the same platform as the database. Prisma ORM is optional; any Postgres client works.", + "You want [Prisma Compute](/docs/compute) to host the TypeScript app as HTTP services next to [Prisma Postgres](/docs/postgres).", + "You want a [preview branch](/docs/compute/branching) that can own its app services and database.", + "You prefer operation-based database pricing with spend limits on paid plans.", +]; + +export const CHOOSE_NEON = [ + "You want Neon Functions, Managed Better Auth, Object Storage, and AI Gateway next to Postgres, declared in one `neon.ts`.", + "You want the UI on a host such as Vercel or Netlify, with Neon running the backend next to the database.", + "You want copy-on-write database branches that include parent data, and instant restore within the plan's history window.", + "You want database compute billed in CU-hours that go to $0 while scale-to-zero has suspended the compute.", +]; + +export type CompareRow = { + feature: string; + neon: string; + prisma: string; + sources: { neon: string; prisma: string }; +}; + +/** Columns run Feature, Neon, Prisma Postgres: alphabetical by product. */ +export const COMPARE_ROWS: readonly CompareRow[] = [ + { + feature: "Database", + neon: "Managed PostgreSQL (Lakebase Postgres) with autoscaling, connection pooling, and scale-to-zero. Works with any language, framework, or ORM that supports Postgres.", + prisma: + "[Prisma Postgres](/docs/postgres) is managed PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, + { + feature: "ORM on the same platform", + neon: "No first-party ORM. Teams pick an external ORM or SQL client, such as Drizzle or Prisma ORM pointed at Neon.", + prisma: + "[Prisma ORM](/docs/orm) is a first-party type-safe ORM on the same platform. It is optional.", + sources: { + neon: "https://neon.com/docs/get-started/backend-overview", + prisma: "https://www.prisma.io/docs/orm", + }, + }, + { + feature: "App hosting", + neon: "Neon Functions run backend logic next to the database. Neon recommends hosting the UI on a separate platform such as Vercel or Netlify.", + prisma: + "[Prisma Compute](/docs/compute) hosts the TypeScript app as HTTP services. It is separate from Prisma Postgres. Compute has no built-in cron scheduler. WebSocket servers are not supported.", + sources: { + neon: "https://neon.com/docs/get-started/full-backend-quickstart", + prisma: "https://www.prisma.io/docs/compute/limitations", + }, + }, + { + feature: "Branches and previews", + neon: "A Neon branch is a copy-on-write clone of Postgres. Object Storage, Functions, Auth, and AI Gateway can branch with the data. The Neon-managed Vercel integration can create a database branch per preview deployment.", + prisma: + "A Prisma [preview branch](/docs/compute/branching) can own its Compute services and databases. Production data is not copied into that database.", + sources: { + neon: "https://neon.com/docs/introduction/branching", + prisma: "https://www.prisma.io/docs/compute/branching", + }, + }, + { + feature: "Database pricing model", + neon: "Compute in CU-hours plus storage and other usage. Launch is $0.106 per CU-hour. Scale is $0.222 per CU-hour. Storage is $0.35 per GB-month on paid plans. Suspended compute is $0.", + prisma: + "Operations plus storage, with spend limits on paid plans. Prisma Postgres Free includes 200,000 operations a month and 2 GB of storage.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + feature: "Auth, object storage, and AI gateway", + neon: "Managed Better Auth, S3-compatible Object Storage, and an AI Gateway ship as Neon backend primitives, declared in `neon.ts`.", + prisma: "Not included. Use integrations with other tools.", + sources: { + neon: "https://neon.com/docs/get-started/backend-overview", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, + { + feature: "Point-in-time restore", + neon: "Instant restore within the project's history window on paid plans: up to 7 days on Launch, up to 30 days on Scale, billed at $0.20 per GB-month. Free lists a 6-hour history window with a 1 GB limit, and does not list instant restore as a plan feature.", + prisma: + "No point-in-time recovery. Paid Prisma Postgres plans include daily backups (7 days on Starter and Pro, 30 days on Business). Free has no backups.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + +export const NEON_FITS = [ + "Backend primitives such as Functions, Object Storage, Managed Better Auth, and AI Gateway should sit next to Postgres.", + "A preview should start from a copy-on-write clone that includes parent data.", + "Instant restore within a history window should be available today.", + "The UI should stay on a frontend host such as Vercel, with Neon creating a database branch per preview deployment.", +]; + +export const PRISMA_FITS = [ + "The database should be PostgreSQL, with SQL and the Postgres ecosystem.", + "Prisma ORM should be available, and optional, on that database.", + "The TypeScript app should deploy as HTTP services on Prisma Compute, in the same Prisma project as the database.", + "Database usage should be billed by operations and storage, with a spend limit on paid plans.", +]; + +export const APART = [ + { + id: "optional-orm", + title: "A first-party ORM, when you want one", + body: "Prisma ORM is a type-safe data layer on the same platform as Prisma Postgres. It is optional. You can skip it and use SQL or another Postgres client.", + }, + { + id: "http-hosting", + title: "Full TypeScript app hosting", + body: "Prisma Compute hosts the TypeScript app as HTTP services next to Prisma Postgres. The current release focuses on HTTP. WebSocket servers are not supported. Compute has no built-in cron scheduler.", + }, + { + id: "preview-app-and-db", + title: "App and database on a preview branch", + body: "A Prisma preview branch can own its application services, deployments, and Prisma Postgres databases. Production data is not copied into that database. See [Compute branching](/docs/compute/branching).", + }, + { + id: "operation-pricing", + title: "Operation-based database pricing", + body: "Prisma Postgres bills database usage by operations and storage. Starter, Pro, and Business list spend limits. Neon bills database compute in CU-hours alongside storage and other usage-based meters.", + }, +]; + +export const PRICING: readonly { + label: string; + neon: string; + prisma: string; + sources: { neon: string; prisma: string }; +}[] = [ + { + label: "Free plan", + neon: "$0 a month. No credit card required. 100 CU-hours per project, 1 GB of Postgres storage per project, 20 GB account total.", + prisma: + "Prisma Postgres Free is $0. No credit card required. 200,000 operations a month, 2 GB of storage, and 50 databases.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Paid plans", + neon: "Launch and Scale are pay-as-you-go with no monthly minimum. Launch is $0.106 per CU-hour. Scale is $0.222 per CU-hour.", + prisma: "Starter $10, Pro $49, Business $129 a month for Prisma Postgres and Prisma Compute.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Database storage on paid plans", + neon: "$0.35 per GB-month. Unlimited storage that grows with usage. Extra branches above the plan allowance are $1.50 per branch-month.", + prisma: + "Starter includes 10 GB, then $2.00 per GB. Pro includes 50 GB, then $1.50 per GB. Business includes 100 GB, then $1.00 per GB.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Spend controls", + neon: "Spending notifications on Launch and Scale. Invoices under $0.50 are not collected.", + prisma: "Spend limits on Starter, Pro, and Business.", + sources: { + neon: "https://neon.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + +export const WRONG_CHOICE = [ + "You want Neon Functions, Managed Better Auth, Object Storage, or AI Gateway as first-party primitives next to Postgres. Prisma Postgres does not include those. See [How a Neon backend fits together](https://neon.com/docs/get-started/backend-overview).", + "You want a preview that starts from a copy-on-write clone of production data. A Prisma preview branch gets its own database; production data is not copied. See [Compute branching](/docs/compute/branching).", + "You need instant restore to a moment inside a history window today. Prisma Postgres has no point-in-time recovery. Paid plans include daily backups. Neon documents instant restore on paid plans at [Neon pricing](https://neon.com/pricing).", + "You want database compute that bills $0 while the instance is suspended, rather than operation-based pricing. Neon CU-hours stop while scale-to-zero has suspended compute; storage still bills.", +]; + +export const FAQ: readonly { question: string; answer: string }[] = [ + { + question: "Does Prisma Postgres copy production data onto preview branches?", + answer: + "No. A Prisma preview branch can own its Compute services and databases, but production data is not copied into that database. A Neon branch is a copy-on-write clone that includes parent data. Source: [Compute branching](/docs/compute/branching) and [Neon branching](https://neon.com/docs/introduction/branching), October 2026.", + }, + { + question: "Does Neon host the frontend?", + answer: + "Neon Functions run backend logic next to the database. Neon's full-stack guide keeps the UI on a host such as Vercel, Netlify, or self-hosted Node. Source: [Build a full backend with Next.js and Neon](https://neon.com/docs/get-started/full-backend-quickstart), October 2026.", + }, + { + question: "Do I need Prisma ORM to use Prisma Postgres?", + answer: + "No. Prisma Postgres is PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional. Source: [Prisma Postgres](/docs/postgres), October 2026.", + }, + { + question: "How do Neon and Prisma Postgres bill for the database?", + answer: + "Neon bills database compute in CU-hours ($0.106 on Launch, $0.222 on Scale) plus $0.35 per GB-month of storage on paid plans. Prisma Postgres bills operations and storage, with spend limits on paid plans. Prisma Postgres Free includes 200,000 operations and 2 GB of storage, with no credit card. Source: [Neon pricing](https://neon.com/pricing) and [Prisma pricing](/pricing), October 2026.", + }, + { + question: "Does Prisma Postgres have point-in-time recovery?", + answer: + "No. Prisma Postgres has no point-in-time recovery. Paid plans include daily backups (7 days on Starter and Pro, 30 days on Business). Neon paid plans list instant restore within a history window of up to 7 days on Launch and 30 days on Scale. Source: [Prisma pricing](/pricing) and [Neon pricing](https://neon.com/pricing), October 2026.", + }, +]; + +export const TOOLS = { + neon: { + name: "Neon", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/neondatabase/neon", + programmingLanguage: "Rust", + url: "https://neon.com", + }, + prisma: { + name: "Prisma Postgres", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/postgres", + }, +} as const; diff --git a/apps/site/src/app/compare/neon/_sections.tsx b/apps/site/src/app/compare/neon/_sections.tsx new file mode 100644 index 00000000000..6815e3335bc --- /dev/null +++ b/apps/site/src/app/compare/neon/_sections.tsx @@ -0,0 +1,426 @@ +import Link from "next/link"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK } from "../table-styles"; +import { renderFaqAnswer } from "@/lib/faq-answer"; +import { + APART, + CHECKED, + CHOOSE_NEON, + CHOOSE_PRISMA, + COMPARE_ROWS, + FAQ, + NEON_AUTHOR, + NEON_FITS, + NEON_TITLE, + NEON_UPDATED, + PRICING, + PRISMA_FITS, + WRONG_CHOICE, +} from "./_content"; + +const INLINE = + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; +const CHOOSE_LABEL = "font-heading text-lg leading-snug font-medium"; + +const PROOF = TESTIMONIALS.filter( + (item) => item.name === "Ron Cohen" || item.name === "Omar López", +); + +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +const SOURCES = [ + { + product: "Neon", + links: [ + { label: "Neon pricing", href: "https://neon.com/pricing" }, + { + label: "How a Neon backend fits together", + href: "https://neon.com/docs/get-started/backend-overview", + }, + { + label: "Build a full backend with Next.js and Neon", + href: "https://neon.com/docs/get-started/full-backend-quickstart", + }, + { label: "Neon branching", href: "https://neon.com/docs/introduction/branching" }, + { + label: "Integrating Neon with Vercel", + href: "https://neon.com/docs/guides/vercel-overview", + }, + { + label: "Apache License 2.0", + href: "https://github.com/neondatabase/neon/blob/main/LICENSE", + }, + ], + }, + { + product: "Prisma Postgres, Prisma ORM, and Prisma Compute", + links: [ + { label: "Prisma Postgres", href: "/docs/postgres" }, + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Prisma Compute", href: "/docs/compute" }, + { label: "Compute limitations", href: "/docs/compute/limitations" }, + { label: "Compute branching", href: "/docs/compute/branching" }, + { label: "Migrate from Neon", href: "/docs/guides/switch-to-prisma-postgres/from-neon" }, + { + label: "existing-database guide", + href: "/docs/prisma-orm/add-to-existing-project/postgresql", + }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +function buildNeonPage(minutes: number) { + return [ +
+

+ {NEON_TITLE} +

+

+ Pick{" "} + + Prisma Postgres + {" "} + when you want managed PostgreSQL with{" "} + + Prisma ORM + {" "} + optional, and{" "} + + Prisma Compute + {" "} + hosting the TypeScript app as HTTP services in the same Prisma project. Pick{" "} + + Neon + {" "} + when you want Postgres plus Functions, Managed Better Auth, Object Storage, and AI Gateway, + with the UI on a host such as Vercel or Netlify. +

+ {comparisonHeroCtas("postgres")} + {comparisonByline({ author: NEON_AUTHOR, updated: NEON_UPDATED, minutes })} + {comparisonDisclosure({ + competitor: "Neon", + checked: CHECKED, + prismaProduct: "Prisma Postgres", + })} +
, +
+
+ + When should I choose Neon or Prisma Postgres? + + {comparisonChooseTable({ + competitor: "Neon", + prisma: "Prisma Postgres", + competitorItems: CHOOSE_NEON, + prismaItems: CHOOSE_PRISMA, + })} +
+ +
+ + How do Neon and Prisma Postgres compare? + +

+ Neon and Prisma Postgres are both managed PostgreSQL. Neon adds backend primitives next to + the database and keeps full-app hosting on a separate platform. Prisma Postgres sits next + to an optional ORM and Prisma Compute. Columns are in alphabetical order. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.neon), renderFaqAnswer(row.prisma)], + }))} + /> +
+ +
+ + Where does each one fit? + +
+
+

Neon

+

+ Neon is managed Postgres that has grown backend primitives around the database. Its{" "} + + backend overview + {" "} + describes Functions, Object Storage, Managed Better Auth, and AI Gateway declared in{" "} + neon.ts, with the UI hosted elsewhere. +

+
    + {NEON_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Prisma Postgres

+

+ Prisma Postgres fits a team that wants PostgreSQL next to the TypeScript app.{" "} + + Prisma ORM + {" "} + is optional.{" "} + + Prisma Compute + {" "} + is separate app hosting in the same Prisma project. +

+
    + {PRISMA_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+ +
+ + {"What does Prisma Postgres do that Neon doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

+
+ ))} +
+
+ +
+ + How much do Neon and Prisma Postgres cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.neon), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. No credit card required. The{" "} + + pricing page + {" "} + lists the paid plans. Neon Free includes 100 CU-hours per project and 1 GB of Postgres + storage per project ( + + Neon pricing + + , {CHECKED}). + + } + /> +
+ +
+ + When is Prisma Postgres the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
+
+ +
+ + How do I switch from Neon to Prisma Postgres? + +
    +
  1. + Create an empty Prisma Postgres database. Prisma ORM is optional. The{" "} + + migrate from Neon + {" "} + guide uses pg_dump and pg_restore over the direct connection. +
  2. +
  3. + Dump the selected Neon branch and database with a direct (non-pooler) connection string, + then restore into Prisma Postgres. The dump copies PostgreSQL schemas and data. It does + not reproduce Neon branch history, Functions, Auth, Object Storage, or other Neon + backend services. +
  4. +
  5. + Point the app at the Prisma Postgres connection string. Host the HTTP app on Prisma + Compute, or keep the frontend host you already use. +
  6. +
+
+ +
+ + How do I switch from Prisma Postgres to Neon? + +
    +
  1. + Create a Neon project and dump Prisma Postgres with pg_dump over the direct + connection, then restore into Neon. Both sides are PostgreSQL. +
  2. +
  3. + Point the app at the Neon DATABASE_URL. Keep using SQL or an ORM; Neon has + no first-party ORM. +
  4. +
  5. + Publish the UI with a provider such as Vercel or Netlify, as the{" "} + + full-backend guide + {" "} + describes. Add Functions, Auth, Object Storage, or AI Gateway in neon.ts if + you need those primitives. Prisma Postgres stays in place until you stop using it. +
  6. +
+
+ +
+ + What do teams say about Prisma Postgres? + +

+ Two teams on what Prisma ORM and Prisma Postgres do for them. +

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )} +
+ +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("postgres"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • + ))} +
+
+ ))} +
+
, + ]; +} + +const NEON_READING = articleReadingStats(buildNeonPage(1)); + +export function getNeonReadingStats() { + return articleReadingStats(buildNeonPage(NEON_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getNeonPageContent() { + return buildNeonPage(NEON_READING.minutes); +} + +/** Render the comparison in the guide article frame. */ +export function NeonPage() { + return {getNeonPageContent()}; +} diff --git a/apps/site/src/app/compare/neon/facts.ts b/apps/site/src/app/compare/neon/facts.ts new file mode 100644 index 00000000000..0a9f37397b3 --- /dev/null +++ b/apps/site/src/app/compare/neon/facts.ts @@ -0,0 +1,79 @@ +import { COMPARE_ROWS, NEON_PATH, NEON_PUBLISHED, NEON_UPDATED, PRICING, TOOLS } from "./_content"; +import { getNeonReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +/** Machine-readable feature and pricing cells with a source on every value. */ +export function getNeonFacts() { + const dateChecked = NEON_UPDATED.iso; + return { + path: NEON_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + neon: { + value: row.neon, + source: row.sources.neon, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + neon: { + value: row.neon, + source: row.sources.neon, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getNeonAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.neon.name, + softwareVersion: TOOLS.neon.softwareVersion, + license: TOOLS.neon.license, + codeRepository: TOOLS.neon.codeRepository, + programmingLanguage: TOOLS.neon.programmingLanguage, + url: TOOLS.neon.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getNeonArticleFields() { + const { wordCount, timeRequired } = getNeonReadingStats(); + return { + datePublished: NEON_PUBLISHED.iso, + dateModified: NEON_UPDATED.iso, + wordCount, + timeRequired, + about: getNeonAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/neon/page.tsx b/apps/site/src/app/compare/neon/page.tsx new file mode 100644 index 00000000000..190fd4eedba --- /dev/null +++ b/apps/site/src/app/compare/neon/page.tsx @@ -0,0 +1,59 @@ +import localFont from "next/font/local"; +import { JsonLd } from "@prisma-docs/ui/components/json-ld"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; +import { createFaqStructuredData } from "@/lib/structured-data"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { FAQ, NEON_AUTHOR, NEON_PATH, NEON_TITLE } from "./_content"; +import { getNeonArticleFields } from "./facts"; +import { NeonPage } from "./_sections"; + +/** + * The docs' code face. Shared with the Kysely comparison so both pages load + * one copy of the unbracketed Mona Sans Mono file. + */ +const monaSansMono = localFont({ + src: "../kysely/mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + +export const metadata = getComparisonMetadata("neon"); + +const faqStructuredData = createFaqStructuredData(NEON_PATH, [...FAQ], NEON_TITLE); + +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("neon"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getNeonArticleFields(), + image: getComparisonOgImageUrl("neon"), + author: comparisonAuthorPerson(NEON_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + +/** Render the comparison and its article, breadcrumb and FAQ structured data. */ +export default function Page() { + return ( + <> + + +
+ +
+ + ); +} diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx new file mode 100644 index 00000000000..4246d05b2c3 --- /dev/null +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Check, Copy } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { getBaseUrl } from "@/lib/url"; +import { COPIED_VISIBLE_MS } from "./copied"; + +const cache = new Map(); + +const COPY_LABEL = "Copy this page as Markdown"; + +const ICON = + "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground/80 transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; + +const KYSELY_PROMPT = `I am choosing between Prisma ORM and Kysely for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which tool fits if I already have SQL I like vs if I want a schema-first workflow. +2. Call out the real tradeoffs (type safety, migrations, raw SQL, dialects) without selling. +3. Give me the first commands to try the one you recommend. + +Ask me up to three short questions if you need my database, team size, or whether I already write SQL.`; + +const MONGOOSE_PROMPT = `I am choosing between Prisma ORM and Mongoose for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which tool fits if my schema lives next to the model in application code vs if I want a schema-first contract file. +2. Call out the real tradeoffs (MongoDB-only vs PostgreSQL too, populate vs include, untyped vs typed aggregations, middleware, migrations, early access) without selling. +3. Give me the first commands to try the one you recommend. + +Ask me up to three short questions if you need whether I already use MongoDB, whether I use populate on arrays of ids, or whether I need PostgreSQL as well.`; + +const CONVEX_PROMPT = `I am choosing between Prisma Postgres and Convex for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which fits if I want standard PostgreSQL vs if I want a reactive database with TypeScript query functions. +2. Call out the real tradeoffs (SQL vs TypeScript queries, realtime WebSocket sync, per-developer fees, hosting) without selling. +3. Give me the first steps to try the one you recommend. + +Ask me up to three short questions if you need whether I need live query updates, whether I want SQL, or whether I already have a Postgres database.`; + +const PLANETSCALE_PROMPT = `I am choosing between Prisma Postgres and PlanetScale for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which fits if I want Prisma ORM optional plus Prisma Compute in one project vs if I want database-first Postgres SKUs, Metal, HA, or Neki with the app on another host. +2. Call out the real tradeoffs ($0 free plan vs $5 single-node, operations vs cluster SKUs, preview branches vs isolated PS-DEV branches, Neki preview not for production) without selling. +3. Give me the first steps to try the one you recommend. + +Ask me up to three short questions if I need Metal/HA/Neki, whether I want the TypeScript app hosted next to Postgres, or whether a $0 database plan matters.`; + +const SUPABASE_PROMPT = `I am choosing between Prisma Postgres and Supabase for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which fits if I want Prisma ORM optional plus Prisma Compute in one project vs if I want Auth, Storage, Realtime, and Edge Functions next to Postgres with the UI elsewhere. +2. Call out the real tradeoffs (operations vs plan-plus-hourly-compute, spend limits vs Spend Cap exclusions, HIPAA on Business $129 vs Team $599 plus add-on, preview branches) without selling. +3. Give me the first steps to try the one you recommend. + +Ask me up to three short questions if I need Auth/Storage/Realtime out of the box, whether I want the TypeScript app hosted next to Postgres, or whether HIPAA/SOC 2/ISO 27001 matter.`; + +const NEON_PROMPT = `I am choosing between Prisma Postgres and Neon for a TypeScript app. Using only what the article states, plus my answers: + +1. Tell me which fits if I want Prisma ORM optional plus Prisma Compute in one project vs if I want Neon Functions, Auth, Object Storage, and AI Gateway with the UI elsewhere. +2. Call out the real tradeoffs (copy-on-write data branches vs preview branches without production data, CU-hours vs operations, instant restore vs daily backups) without selling. +3. Give me the first steps to try the one you recommend. + +Ask me up to three short questions if I need a copy of production data on previews, whether I need Neon's backend primitives, or whether I want the TypeScript app hosted next to Postgres.`; + +/** Prompt every Open-with-AI logo prefills. The page's `.md` alternate is the source of truth. */ +export function choosePrompt(pageMdUrl: string) { + const body = pageMdUrl.includes("/compare/planetscale.md") + ? PLANETSCALE_PROMPT + : pageMdUrl.includes("/compare/supabase.md") + ? SUPABASE_PROMPT + : pageMdUrl.includes("/compare/neon.md") + ? NEON_PROMPT + : pageMdUrl.includes("/compare/convex.md") + ? CONVEX_PROMPT + : pageMdUrl.includes("/compare/mongoose.md") + ? MONGOOSE_PROMPT + : KYSELY_PROMPT; + return `Read this article and treat it as the source of truth: + +${pageMdUrl} + +${body}`; +} + +function toolLinks(markdownUrl: string) { + const q = choosePrompt(markdownUrl); + return [ + { + name: "ChatGPT", + href: `https://chatgpt.com/?${new URLSearchParams({ hints: "search", q })}`, + tooltip: "Opens ChatGPT with this comparison and a prompt to help you choose.", + icon: , + }, + { + name: "Claude", + href: `https://claude.ai/new?${new URLSearchParams({ q })}`, + tooltip: "Opens Claude with this comparison and a prompt to help you choose.", + icon: , + }, + { + name: "Gemini", + href: `https://gemini.google.com/app?${new URLSearchParams({ q })}`, + tooltip: "Opens Gemini with this comparison and a prompt to help you choose.", + icon: , + }, + { + name: "Perplexity", + href: `https://www.perplexity.ai/search/new?${new URLSearchParams({ q })}`, + tooltip: "Opens Perplexity with this comparison and a prompt to help you choose.", + icon: , + }, + { + name: "Grok", + href: `https://grok.com/?${new URLSearchParams({ q })}`, + tooltip: "Opens Grok with this comparison and a prompt to help you choose.", + icon: , + }, + ]; +} + +function Mark({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** OpenAI mark from the docs page-actions. */ +function ChatGptMark() { + return ( + + + + ); +} + +/** Anthropic mark from the docs page-actions. */ +function ClaudeMark() { + return ( + + + + ); +} + +function GeminiMark() { + return ( + + + + ); +} + +function PerplexityMark() { + return ( + + + + ); +} + +function GrokMark() { + return ( + + + + ); +} + +/** Copy icon, then the five AI marks. No dropdown. No shared label over the row. */ +export function PageAiActions({ markdownPath }: { markdownPath: string }) { + const [copied, setCopied] = useState(false); + const [loading, setLoading] = useState(false); + const [origin, setOrigin] = useState(""); + const timer = useRef>(undefined); + + useEffect(() => { + setOrigin(window.location.origin); + return () => clearTimeout(timer.current); + }, []); + + const tools = useMemo(() => { + const markdownUrl = new URL(markdownPath, origin || getBaseUrl()).toString(); + return toolLinks(markdownUrl); + }, [markdownPath, origin]); + + async function copyPage() { + setLoading(true); + try { + let text = cache.get(markdownPath); + if (!text) { + const response = await fetch(markdownPath); + if (!response.ok) return; + text = await response.text(); + cache.set(markdownPath, text); + } + await navigator.clipboard.writeText(text); + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), COPIED_VISIBLE_MS); + } catch { + return; + } finally { + setLoading(false); + } + } + + return ( + +
+ + + + + {copied ? "Copied" : COPY_LABEL} + + + {tools.map((tool) => ( + + + + {tool.icon} + + + + {tool.tooltip} + + + ))} +
+
+ ); +} diff --git a/apps/site/src/app/compare/planetscale.json/route.ts b/apps/site/src/app/compare/planetscale.json/route.ts new file mode 100644 index 00000000000..237d4031b00 --- /dev/null +++ b/apps/site/src/app/compare/planetscale.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getPlanetScaleFacts } from "../planetscale/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getPlanetScaleFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/planetscale/_content.ts b/apps/site/src/app/compare/planetscale/_content.ts new file mode 100644 index 00000000000..3f0243ee405 --- /dev/null +++ b/apps/site/src/app/compare/planetscale/_content.ts @@ -0,0 +1,255 @@ +export const PLANETSCALE_PATH = "/compare/planetscale"; + +export const PLANETSCALE_TITLE = + "Prisma Postgres vs PlanetScale: TypeScript app hosting or database-first Postgres?"; + +export const PLANETSCALE_DESCRIPTION = + "Both are managed PostgreSQL. Pick Prisma Postgres for optional Prisma ORM, Prisma Compute, and a $0 free plan. Pick PlanetScale for database-first SKUs, Metal, and Neki. Checked October 2026."; + +export const CHECKED = "October 2026"; + +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const PLANETSCALE_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const PLANETSCALE_PUBLISHED = { iso: "2026-10-19", label: "October 2026" } as const; +export const PLANETSCALE_UPDATED = { iso: "2026-10-19", label: "October 2026" } as const; + +export const CHOOSE_PRISMA = [ + "You want [Prisma ORM](/docs/orm) available on the same platform as the database. Prisma ORM is optional; any Postgres client works.", + "You want [Prisma Compute](/docs/compute) to host the TypeScript app as HTTP services next to [Prisma Postgres](/docs/postgres).", + "You want a [preview branch](/docs/compute/branching) that can own its app services and database.", + "You want a $0 free plan with no credit card, then operation-based pricing with spend limits on paid plans.", +]; + +export const CHOOSE_PLANETSCALE = [ + "You want a database-first Postgres SKU, with application hosting on another provider.", + "You want high-availability Postgres with one primary and two replicas across three availability zones, or Metal with local NVMe.", + "You want PlanetScale database branches as isolated database deployments, billed as their own clusters.", + "You want horizontally sharded Postgres with Neki, currently in platform preview, and you accept that PlanetScale says not to run production on it during the preview.", +]; + +export type CompareRow = { + feature: string; + planetscale: string; + prisma: string; + sources: { planetscale: string; prisma: string }; +}; + +/** Columns run Feature, PlanetScale, Prisma Postgres: alphabetical by product. */ +export const COMPARE_ROWS: readonly CompareRow[] = [ + { + feature: "Database", + planetscale: + "Managed PostgreSQL with branching and connection pooling. Single-node instances start at $5 a month. HA is one primary and two replicas. Metal uses local NVMe.", + prisma: + "[Prisma Postgres](/docs/postgres) is managed PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, + { + feature: "ORM on the same platform", + planetscale: "No first-party ORM. Teams pick an external ORM or SQL client.", + prisma: + "[Prisma ORM](/docs/orm) is a first-party type-safe ORM on the same platform. It is optional.", + sources: { + planetscale: "https://planetscale.com/docs/postgres/branching", + prisma: "https://www.prisma.io/docs/orm", + }, + }, + { + feature: "App hosting", + planetscale: "Application hosting stays with another provider. PlanetScale is the database.", + prisma: + "[Prisma Compute](/docs/compute) hosts the TypeScript app as HTTP services. It is separate from Prisma Postgres. Compute has no built-in cron scheduler. WebSocket servers are not supported.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/docs/compute/limitations", + }, + }, + { + feature: "Branches and previews", + planetscale: + "A PlanetScale Postgres branch is an isolated database deployment. There is no data replication between branches. Branches created from the Branches page start as PS-DEV at $5 a month and do not copy schema or data. Restore from a backup to include schema and data.", + prisma: + "A Prisma [preview branch](/docs/compute/branching) can own its Compute services and databases. Production data is not copied into that database.", + sources: { + planetscale: "https://planetscale.com/docs/postgres/branching", + prisma: "https://www.prisma.io/docs/compute/branching", + }, + }, + { + feature: "Free plan", + planetscale: "No $0 Postgres plan. Single-node Postgres instances start at $5 a month.", + prisma: + "Prisma Postgres Free is $0. No credit card required. 200,000 operations a month, 2 GB of storage, and 50 databases.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + feature: "Database pricing model", + planetscale: + "Resource-based SKUs: cluster size, storage, backups, and network usage. Prices on the pricing page are AWS us-east-1 and vary by region.", + prisma: + "Operations plus storage, with spend limits on paid plans. Starter is $10 a month with 1 million operations and 10 GB included.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + feature: "Horizontal Postgres sharding", + planetscale: + "Neki is horizontally sharded Postgres, in platform preview as of September 2026. PlanetScale says not to run production workloads on Neki during the preview. Neki has no single-node configuration.", + prisma: "Standard PostgreSQL. No first-party horizontal sharding product.", + sources: { + planetscale: "https://planetscale.com/blog/introducing-neki", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, +]; + +export const PLANETSCALE_FITS = [ + "The database should be a PlanetScale Postgres SKU, with the app on another host.", + "High availability should be one primary and two replicas, or Metal with local NVMe from $50 a month.", + "Database branches should be isolated clusters, billed separately, without copying production data by default.", + "Horizontally sharded Postgres with Neki is acceptable as a platform preview, not production.", +]; + +export const PRISMA_FITS = [ + "The database should be PostgreSQL, with SQL and the Postgres ecosystem.", + "Prisma ORM should be available, and optional, on that database.", + "The TypeScript app should deploy as HTTP services on Prisma Compute, in the same Prisma project as the database.", + "The database should have a $0 free plan, then operations and storage with spend limits on paid plans.", +]; + +export const APART = [ + { + id: "optional-orm", + title: "A first-party ORM, when you want one", + body: "Prisma ORM is a type-safe data layer on the same platform as Prisma Postgres. It is optional. You can skip it and use SQL or another Postgres client.", + }, + { + id: "http-hosting", + title: "Full TypeScript app hosting", + body: "Prisma Compute hosts the TypeScript app as HTTP services next to Prisma Postgres. The current release focuses on HTTP. WebSocket servers are not supported. Compute has no built-in cron scheduler.", + }, + { + id: "preview-app-and-db", + title: "App and database on a preview branch", + body: "A Prisma preview branch can own its application services, deployments, and Prisma Postgres databases. Production data is not copied into that database. See [Compute branching](/docs/compute/branching).", + }, + { + id: "free-tier", + title: "A $0 free plan", + body: "Prisma Postgres Free is $0, with no credit card. PlanetScale Postgres single-node instances start at $5 a month. There is no $0 PlanetScale Postgres SKU on the pricing page checked October 2026.", + }, +]; + +export const PRICING: readonly { + label: string; + planetscale: string; + prisma: string; + sources: { planetscale: string; prisma: string }; +}[] = [ + { + label: "Lowest listed Postgres price", + planetscale: "Single-node PS-5 is $5 a month on AWS us-east-1. There is no $0 Postgres plan.", + prisma: + "Prisma Postgres Free is $0. No credit card required. 200,000 operations a month, 2 GB of storage, and 50 databases.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "High availability", + planetscale: + "HA Postgres is one primary and two replicas. PS-5 HA is $15 a month on AWS us-east-1.", + prisma: "Starter $10, Pro $49, Business $129 a month for Prisma Postgres and Prisma Compute.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Metal", + planetscale: + "Postgres Metal starts at $50 a month for M-10 arm64 with 10 GiB included NVMe on AWS us-east-1. Storage is in the SKU rate.", + prisma: "No Metal SKU. Storage is billed by gigabyte on paid plans after the included amount.", + sources: { + planetscale: "https://planetscale.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Neki", + planetscale: + "Horizontally sharded Postgres in platform preview. No single-node SKU. Network-attached Neki starts at $30 a month per shard (PS-10 arm64 HA) plus routers, storage, backups, and network. PlanetScale says not to run production on it during the preview.", + prisma: "Not offered. Prisma Postgres is standard PostgreSQL.", + sources: { + planetscale: "https://planetscale.com/blog/introducing-neki", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, +]; + +export const WRONG_CHOICE = [ + "You want database-first SKUs, Metal NVMe, or HA across three availability zones as the product. Prisma Postgres does not sell those cluster sizes. See [PlanetScale pricing](https://planetscale.com/pricing).", + "You want horizontally sharded Postgres. Neki is PlanetScale's product for that, in platform preview. Prisma Postgres is standard PostgreSQL. PlanetScale says not to run production on Neki during the preview. See [Introducing Neki](https://planetscale.com/blog/introducing-neki).", + "You want the database on PlanetScale and the app on another host, and a $5 cluster is acceptable because you do not need a $0 database plan.", +]; + +export const FAQ: readonly { question: string; answer: string }[] = [ + { + question: "Does PlanetScale Postgres have a free plan?", + answer: + "No $0 Postgres SKU is listed. Single-node Postgres instances start at $5 a month on AWS us-east-1. Prisma Postgres Free is $0, with no credit card, 200,000 operations, and 2 GB of storage. Source: [PlanetScale pricing](https://planetscale.com/pricing) and [Prisma pricing](/pricing), October 2026.", + }, + { + question: "Do I need Prisma ORM to use Prisma Postgres?", + answer: + "No. Prisma Postgres is PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional. Source: [Prisma Postgres](/docs/postgres), October 2026.", + }, + { + question: "Does PlanetScale host the TypeScript app?", + answer: + "No. PlanetScale is the database. Application hosting stays with another provider. Prisma Compute hosts TypeScript HTTP services next to Prisma Postgres. Source: [PlanetScale pricing](https://planetscale.com/pricing) and [Prisma Compute](/docs/compute), October 2026.", + }, + { + question: "What is Neki?", + answer: + "Neki is PlanetScale's horizontally sharded Postgres. It entered platform preview on 10 September 2026. PlanetScale says you should not run production workloads on Neki during the preview. Each shard is an HA Postgres cluster; there is no single-node Neki SKU. Source: [Introducing Neki](https://planetscale.com/blog/introducing-neki) and [PlanetScale pricing](https://planetscale.com/pricing), October 2026.", + }, + { + question: "Do PlanetScale Postgres branches copy production data?", + answer: + "No by default. Branches created from the Branches page start as isolated PS-DEV databases at $5 a month and do not copy schema or data. Restore from a backup to include schema and data. There is no replication between branches. Source: [PlanetScale Postgres branching](https://planetscale.com/docs/postgres/branching), October 2026.", + }, +]; + +export const TOOLS = { + planetscale: { + name: "PlanetScale", + softwareVersion: "current", + license: "https://planetscale.com/legal/siteterms", + codeRepository: "https://github.com/planetscale", + programmingLanguage: "Go", + url: "https://planetscale.com", + }, + prisma: { + name: "Prisma Postgres", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/postgres", + }, +} as const; diff --git a/apps/site/src/app/compare/planetscale/_sections.tsx b/apps/site/src/app/compare/planetscale/_sections.tsx new file mode 100644 index 00000000000..ee78dd0e6d6 --- /dev/null +++ b/apps/site/src/app/compare/planetscale/_sections.tsx @@ -0,0 +1,418 @@ +import Link from "next/link"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK } from "../table-styles"; +import { renderFaqAnswer } from "@/lib/faq-answer"; +import { + APART, + CHECKED, + CHOOSE_PLANETSCALE, + CHOOSE_PRISMA, + COMPARE_ROWS, + FAQ, + PLANETSCALE_AUTHOR, + PLANETSCALE_FITS, + PLANETSCALE_TITLE, + PLANETSCALE_UPDATED, + PRICING, + PRISMA_FITS, + WRONG_CHOICE, +} from "./_content"; + +const INLINE = + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; +const CHOOSE_LABEL = "font-heading text-lg leading-snug font-medium"; + +const PROOF = TESTIMONIALS.filter( + (item) => item.name === "Ron Cohen" || item.name === "Omar López", +); + +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +const SOURCES = [ + { + product: "PlanetScale", + links: [ + { label: "PlanetScale pricing", href: "https://planetscale.com/pricing" }, + { label: "Postgres branching", href: "https://planetscale.com/docs/postgres/branching" }, + { + label: "Neki is now available in platform preview", + href: "https://planetscale.com/changelog/neki", + }, + { label: "Introducing Neki", href: "https://planetscale.com/blog/introducing-neki" }, + { + label: "Neki platform preview limitations", + href: "https://planetscale.com/docs/neki/platform-preview-limitations", + }, + { label: "Site terms", href: "https://planetscale.com/legal/siteterms" }, + ], + }, + { + product: "Prisma Postgres, Prisma ORM, and Prisma Compute", + links: [ + { label: "Prisma Postgres", href: "/docs/postgres" }, + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Prisma Compute", href: "/docs/compute" }, + { label: "Compute limitations", href: "/docs/compute/limitations" }, + { label: "Compute branching", href: "/docs/compute/branching" }, + { + label: "Import from an existing database", + href: "/docs/postgres/import-from-existing-database", + }, + { + label: "existing-database guide", + href: "/docs/prisma-orm/add-to-existing-project/postgresql", + }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +function buildPlanetScalePage(minutes: number) { + return [ +
+

+ {PLANETSCALE_TITLE} +

+

+ Pick{" "} + + Prisma Postgres + {" "} + when you want managed PostgreSQL with{" "} + + Prisma ORM + {" "} + optional, and{" "} + + Prisma Compute + {" "} + hosting the TypeScript app as HTTP services in the same Prisma project. Pick{" "} + + PlanetScale + {" "} + when you want database-first Postgres SKUs, Metal, high availability, or Neki, with + application hosting on another provider. +

+ {comparisonHeroCtas("postgres")} + {comparisonByline({ author: PLANETSCALE_AUTHOR, updated: PLANETSCALE_UPDATED, minutes })} + {comparisonDisclosure({ + competitor: "PlanetScale", + checked: CHECKED, + prismaProduct: "Prisma Postgres", + })} +
, +
+
+ + When should I choose PlanetScale or Prisma Postgres? + + {comparisonChooseTable({ + competitor: "PlanetScale", + prisma: "Prisma Postgres", + competitorItems: CHOOSE_PLANETSCALE, + prismaItems: CHOOSE_PRISMA, + })} +
+ +
+ + How do PlanetScale and Prisma Postgres compare? + +

+ PlanetScale and Prisma Postgres are both managed PostgreSQL. PlanetScale sells + database-first cluster SKUs, including Metal and Neki. Prisma Postgres sits next to an + optional ORM and Prisma Compute. Columns are in alphabetical order. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.planetscale), renderFaqAnswer(row.prisma)], + }))} + /> +
+ +
+ + Where does each one fit? + +
+
+

PlanetScale

+

+ PlanetScale is a database-first Postgres platform. Its{" "} + + pricing page + {" "} + lists single-node, high-availability, and Metal SKUs, plus Neki for horizontally + sharded Postgres. Application hosting stays with another provider. +

+
    + {PLANETSCALE_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Prisma Postgres

+

+ Prisma Postgres fits a team that wants PostgreSQL next to the TypeScript app.{" "} + + Prisma ORM + {" "} + is optional.{" "} + + Prisma Compute + {" "} + is separate app hosting in the same Prisma project. +

+
    + {PRISMA_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+ +
+ + {"What does Prisma Postgres do that PlanetScale doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

+
+ ))} +
+
+ +
+ + How much do PlanetScale and Prisma Postgres cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.planetscale), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. No credit card required. The{" "} + + pricing page + {" "} + lists the paid plans. PlanetScale single-node Postgres starts at $5 a month on AWS + us-east-1. There is no $0 Postgres SKU ( + + PlanetScale pricing + + , {CHECKED}). + + } + /> +
+ +
+ + When is Prisma Postgres the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
+
+ +
+ + How do I switch from PlanetScale to Prisma Postgres? + +
    +
  1. + Create an empty Prisma Postgres database. Prisma ORM is optional. The{" "} + + import from an existing database + {" "} + guide uses pg_dump and pg_restore over the direct connection. +
  2. +
  3. + Dump PlanetScale Postgres with a direct (non-PgBouncer) connection string, then restore + into Prisma Postgres. The dump copies PostgreSQL schemas and data. It does not reproduce + PlanetScale branch history, Metal SKUs, or a Neki shard topology. +
  4. +
  5. + Point the app at the Prisma Postgres connection string. Host the HTTP app on Prisma + Compute, or keep the host you already use. +
  6. +
+
+ +
+ + How do I switch from Prisma Postgres to PlanetScale? + +
    +
  1. + Create a PlanetScale Postgres cluster and dump Prisma Postgres with pg_dump{" "} + over the direct connection, then restore into PlanetScale. Both sides are PostgreSQL. +
  2. +
  3. + Point the app at the PlanetScale connection string. Keep using SQL or an ORM; + PlanetScale has no first-party ORM. +
  4. +
  5. + Keep application hosting on a separate provider. Prisma Postgres stays in place until + you stop using it. +
  6. +
+
+ +
+ + What do teams say about Prisma Postgres? + +

+ Two teams on what Prisma ORM and Prisma Postgres do for them. +

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )} +
+ +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("postgres"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • + ))} +
+
+ ))} +
+
, + ]; +} + +const PLANETSCALE_READING = articleReadingStats(buildPlanetScalePage(1)); + +export function getPlanetScaleReadingStats() { + return articleReadingStats(buildPlanetScalePage(PLANETSCALE_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getPlanetScalePageContent() { + return buildPlanetScalePage(PLANETSCALE_READING.minutes); +} + +/** Render the comparison in the guide article frame. */ +export function PlanetScalePage() { + return {getPlanetScalePageContent()}; +} diff --git a/apps/site/src/app/compare/planetscale/facts.ts b/apps/site/src/app/compare/planetscale/facts.ts new file mode 100644 index 00000000000..dbe188a8cff --- /dev/null +++ b/apps/site/src/app/compare/planetscale/facts.ts @@ -0,0 +1,86 @@ +import { + COMPARE_ROWS, + PLANETSCALE_PATH, + PLANETSCALE_PUBLISHED, + PLANETSCALE_UPDATED, + PRICING, + TOOLS, +} from "./_content"; +import { getPlanetScaleReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +/** Machine-readable feature and pricing cells with a source on every value. */ +export function getPlanetScaleFacts() { + const dateChecked = PLANETSCALE_UPDATED.iso; + return { + path: PLANETSCALE_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + planetscale: { + value: row.planetscale, + source: row.sources.planetscale, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + planetscale: { + value: row.planetscale, + source: row.sources.planetscale, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getPlanetScaleAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.planetscale.name, + softwareVersion: TOOLS.planetscale.softwareVersion, + license: TOOLS.planetscale.license, + codeRepository: TOOLS.planetscale.codeRepository, + programmingLanguage: TOOLS.planetscale.programmingLanguage, + url: TOOLS.planetscale.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getPlanetScaleArticleFields() { + const { wordCount, timeRequired } = getPlanetScaleReadingStats(); + return { + datePublished: PLANETSCALE_PUBLISHED.iso, + dateModified: PLANETSCALE_UPDATED.iso, + wordCount, + timeRequired, + about: getPlanetScaleAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/planetscale/page.tsx b/apps/site/src/app/compare/planetscale/page.tsx new file mode 100644 index 00000000000..1882b68a7b8 --- /dev/null +++ b/apps/site/src/app/compare/planetscale/page.tsx @@ -0,0 +1,59 @@ +import localFont from "next/font/local"; +import { JsonLd } from "@prisma-docs/ui/components/json-ld"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; +import { createFaqStructuredData } from "@/lib/structured-data"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { FAQ, PLANETSCALE_AUTHOR, PLANETSCALE_PATH, PLANETSCALE_TITLE } from "./_content"; +import { getPlanetScaleArticleFields } from "./facts"; +import { PlanetScalePage } from "./_sections"; + +/** + * The docs' code face. Shared with the Kysely comparison so both pages load + * one copy of the unbracketed Mona Sans Mono file. + */ +const monaSansMono = localFont({ + src: "../kysely/mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + +export const metadata = getComparisonMetadata("planetscale"); + +const faqStructuredData = createFaqStructuredData(PLANETSCALE_PATH, [...FAQ], PLANETSCALE_TITLE); + +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("planetscale"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getPlanetScaleArticleFields(), + image: getComparisonOgImageUrl("planetscale"), + author: comparisonAuthorPerson(PLANETSCALE_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + +/** Render the comparison and its article, breadcrumb and FAQ structured data. */ +export default function Page() { + return ( + <> + + +
+ +
+ + ); +} diff --git a/apps/site/src/app/compare/pricing-table.tsx b/apps/site/src/app/compare/pricing-table.tsx new file mode 100644 index 00000000000..12c63b8da79 --- /dev/null +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import { + QUIET_NOTE, + SCROLL_HINT, + STICKY, + TABLE_FRAME, + TABLE_ROW, + TABLE_STACK, + TD, + TH_COL, + TH_ROW, +} from "./table-styles"; +import { TableScrollRegion } from "./table-scroll-region"; + +export type PricingColumn = { + label: string; + /** Anchor kept on the column header so older links to a product's pricing still land here. */ + id?: string; +}; + +export type PricingRow = { + label: string; + /** One cell per column, in column order. */ + cells: readonly ReactNode[]; +}; + +export type PricingTableProps = { + /** Id of the section heading that names the table for screen readers. */ + labelledBy: string; + /** Header over the row labels. */ + corner: string; + columns: readonly PricingColumn[]; + rows: readonly PricingRow[]; + /** One short line under the table, such as a free tier and a link to the full pricing. */ + note?: ReactNode; +}; + +/** + * Cost side by side: one column per product, one row per thing you could pay + * for. Rows can name paid tiers as well as licenses and hosting. Below 640px + * each row stacks as a card with `data-label` on every cell. The Markdown + * serializer reads these props directly, so it never renders this component. + */ +export function ComparisonPricingTable({ + labelledBy, + corner, + columns, + rows, + note, +}: PricingTableProps) { + const columnWidth = `${78 / columns.length}%`; + return ( + <> + + + + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.cells.map((cell, index) => ( + + ))} + + ))} + +
+ {corner} + + {column.label} +
+ {row.label} + + {cell} +
+
+ {note ?

{note}

: null} + + ); +} diff --git a/apps/site/src/app/compare/proof-cards.tsx b/apps/site/src/app/compare/proof-cards.tsx new file mode 100644 index 00000000000..3d6270d0d03 --- /dev/null +++ b/apps/site/src/app/compare/proof-cards.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +export type ProofItem = { + name: string; + role: string; + company: string; + quote: string; + logo: { src: string; width: number }; +}; + +/** + * Separate quote cards: own surface, padding, and gap. Not a fused slab. + * Called as a function so Markdown still sees the figures. + */ +export function comparisonProofCards(items: readonly ProofItem[]): ReactNode { + return ( +
+ {items.map((item) => ( +
+ +
+ “{item.quote}” +
+
+ {item.name}, {item.role},{" "} + {item.company} +
+
+ ))} +
+ ); +} diff --git a/apps/site/src/app/compare/reading-time.ts b/apps/site/src/app/compare/reading-time.ts new file mode 100644 index 00000000000..c4bcbd63071 --- /dev/null +++ b/apps/site/src/app/compare/reading-time.ts @@ -0,0 +1,70 @@ +import { isValidElement, type ReactNode } from "react"; + +/** + * `CompareTable` and `ComparisonPricingTable` carry their text in props, not + * children, so the walker reads the same props the Markdown serializer does. + */ +type TableLikeProps = { + children?: ReactNode; + "aria-hidden"?: boolean | "true"; + corner?: string; + columns?: readonly (string | { label: string })[]; + rows?: readonly { label?: string; cells: readonly ReactNode[] }[]; + note?: ReactNode; +}; + +/** + * Midpoint of the usual 200–250 words-per-minute silent-reading range. + * Minutes are rounded to the nearest whole minute, at least 1. + */ +export const WORDS_PER_MINUTE = 225; + +/** Walk a React tree for visible words. Skips aria-hidden nodes (icons, tooltips). */ +export function countWords(node: ReactNode): number { + const parts: string[] = []; + function walk(value: ReactNode) { + if (value == null || typeof value === "boolean") return; + if (typeof value === "string" || typeof value === "number") { + parts.push(String(value)); + return; + } + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (!isValidElement(value)) { + return; + } + if (value.props["aria-hidden"] === true || value.props["aria-hidden"] === "true") return; + const { corner, columns, rows, note } = value.props; + if (Array.isArray(rows)) { + walk(corner); + for (const column of columns ?? []) walk(typeof column === "string" ? column : column.label); + for (const row of rows) { + walk(row.label); + row.cells.forEach(walk); + } + walk(note); + } + walk(value.props.children); + } + walk(node); + const text = parts.join(" ").replace(/\s+/g, " ").trim(); + if (!text) return 0; + return text.split(" ").length; +} + +export function readingMinutes(words: number): number { + return Math.max(1, Math.round(words / WORDS_PER_MINUTE)); +} + +/** Schema.org Duration for an Article's timeRequired. */ +export function timeRequiredIso(minutes: number): string { + return `PT${minutes}M`; +} + +export function articleReadingStats(node: ReactNode) { + const words = countWords(node); + const minutes = readingMinutes(words); + return { wordCount: words, minutes, timeRequired: timeRequiredIso(minutes) }; +} diff --git a/apps/site/src/app/compare/supabase.json/route.ts b/apps/site/src/app/compare/supabase.json/route.ts new file mode 100644 index 00000000000..8afd477c214 --- /dev/null +++ b/apps/site/src/app/compare/supabase.json/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getSupabaseFacts } from "../supabase/facts"; + +/** Machine-readable comparison facts, each value with a source URL and dateChecked. */ +export function GET() { + return NextResponse.json(getSupabaseFacts(), { + headers: { + "Cache-Control": "public, max-age=300, s-maxage=3600", + }, + }); +} diff --git a/apps/site/src/app/compare/supabase/_content.ts b/apps/site/src/app/compare/supabase/_content.ts new file mode 100644 index 00000000000..b668ad34121 --- /dev/null +++ b/apps/site/src/app/compare/supabase/_content.ts @@ -0,0 +1,261 @@ +export const SUPABASE_PATH = "/compare/supabase"; + +export const SUPABASE_TITLE = + "Prisma Postgres vs Supabase: what's the difference and which should I pick?"; + +export const SUPABASE_DESCRIPTION = + "Both are managed PostgreSQL. Pick Prisma Postgres for optional Prisma ORM and Prisma Compute in one project. Pick Supabase for Auth, Storage, Realtime, and Edge Functions. Checked October 2026."; + +export const CHECKED = "October 2026"; + +/** The blog's author page and photo; `/blog` is rewritten to the blog app. */ +export const SUPABASE_AUTHOR = { + name: "Martin Janse van Rensburg", + path: "/blog/author/martin-janse-van-rensburg", + image: "/blog/authors/martin-janse-van-rensburg.png", +} as const; + +export const SUPABASE_PUBLISHED = { iso: "2026-10-19", label: "October 2026" } as const; +export const SUPABASE_UPDATED = { iso: "2026-10-19", label: "October 2026" } as const; + +export const CHOOSE_PRISMA = [ + "You want [Prisma ORM](/docs/orm) available on the same platform as the database. Prisma ORM is optional; any Postgres client works.", + "You want [Prisma Compute](/docs/compute) to host the TypeScript app as HTTP services next to [Prisma Postgres](/docs/postgres).", + "You want a [preview branch](/docs/compute/branching) that can own its app services and database.", + "You prefer operation-based database pricing with spend limits on paid plans, and HIPAA, SOC 2, and ISO 27001 on Business at $129 a month.", +]; + +export const CHOOSE_SUPABASE = [ + "You want Auth, Storage, Realtime, auto-generated APIs, and Edge Functions next to Postgres, from the same vendor.", + "You want the UI on a host such as Vercel, with Supabase running the backend next to the database.", + "You want isolated Supabase preview environments for the backend, with frontend previews on an external host.", + "You want a Pro plan from $25 a month plus hourly compute, and you accept that the Spend Cap does not cover database compute.", +]; + +export type CompareRow = { + feature: string; + supabase: string; + prisma: string; + sources: { supabase: string; prisma: string }; +}; + +/** Columns run Feature, Supabase, Prisma Postgres: competitor first, like the Neon page. */ +export const COMPARE_ROWS: readonly CompareRow[] = [ + { + feature: "Database", + supabase: + "Managed PostgreSQL. A project can be used as a standalone Postgres database with any Postgres client. Auth, Storage, Realtime, and Edge Functions are included and optional.", + prisma: + "[Prisma Postgres](/docs/postgres) is managed PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, + { + feature: "ORM on the same platform", + supabase: + "Auto-generated APIs and client libraries. Teams pick an external ORM or SQL client, such as Prisma ORM pointed at Supabase.", + prisma: + "[Prisma ORM](/docs/orm) is a first-party type-safe ORM on the same platform. It is optional.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/docs/orm", + }, + }, + { + feature: "App hosting", + supabase: + "Edge Functions run server-side logic next to the database. Full app and frontend hosting remains on a separate platform.", + prisma: + "[Prisma Compute](/docs/compute) hosts the TypeScript app as HTTP services. It is separate from Prisma Postgres. Compute has no built-in cron scheduler. WebSocket servers are not supported.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/docs/compute/limitations", + }, + }, + { + feature: "Branches and previews", + supabase: + "Supabase branches are separate environments with their own instance and API credentials. New branches are data-less by default. Frontend preview hosting integrates with an external host. Branching compute is $0.01344 per branch per hour on Pro and Team.", + prisma: + "A Prisma [preview branch](/docs/compute/branching) can own its Compute services and databases. Production data is not copied into that database.", + sources: { + supabase: "https://supabase.com/docs/guides/deployment/branching", + prisma: "https://www.prisma.io/docs/compute/branching", + }, + }, + { + feature: "Auth, Storage, and Realtime", + supabase: + "Auth, file Storage, Realtime, auto-generated APIs, and Edge Functions ship with every project.", + prisma: "Not included. Use integrations with other tools.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/docs/postgres", + }, + }, + { + feature: "Database pricing model", + supabase: + "Organization plan plus compute billed separately per project. Pro is from $25 a month. Pro and Team include $10 a month in compute credits, enough for one Micro instance. Additional compute is billed hourly.", + prisma: + "Operations plus storage, with spend limits on paid plans. Prisma Postgres Free includes 200,000 operations a month and 2 GB of storage.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + feature: "HIPAA, SOC 2, and ISO 27001", + supabase: + "SOC 2 and ISO 27001 are included on Team at $599 a month. HIPAA is a paid add-on on Team and Enterprise. Not listed on Free or Pro.", + prisma: + "HIPAA, SOC 2 Type II, and ISO 27001 are on the Business plan at $129 a month, with a signed BAA for HIPAA. Not available on Free, Starter, or Pro. GDPR is on every plan.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/postgres/hipaa", + }, + }, +]; + +export const SUPABASE_FITS = [ + "Auth, Storage, Realtime, auto-generated APIs, and Edge Functions should sit next to Postgres from one vendor.", + "The UI should stay on a frontend host, with Supabase isolating the backend on a preview branch.", + "You want a Pro plan from $25 a month and are ready to pay compute by the hour beyond the included Micro credits.", + "Point-in-time recovery is available as a $100 per month add-on per 7 days of retention.", +]; + +export const PRISMA_FITS = [ + "The database should be PostgreSQL, with SQL and the Postgres ecosystem.", + "Prisma ORM should be available, and optional, on that database.", + "The TypeScript app should deploy as HTTP services on Prisma Compute, in the same Prisma project as the database.", + "Database usage should be billed by operations and storage, with a spend limit on paid plans. HIPAA, SOC 2, and ISO 27001 start on Business at $129 a month.", +]; + +export const APART = [ + { + id: "optional-orm", + title: "A first-party ORM, when you want one", + body: "Prisma ORM is a type-safe data layer on the same platform as Prisma Postgres. It is optional. You can skip it and use SQL or another Postgres client.", + }, + { + id: "http-hosting", + title: "Full TypeScript app hosting", + body: "Prisma Compute hosts the TypeScript app as HTTP services next to Prisma Postgres. The current release focuses on HTTP. WebSocket servers are not supported. Compute has no built-in cron scheduler.", + }, + { + id: "preview-app-and-db", + title: "App and database on a preview branch", + body: "A Prisma preview branch can own its application services, deployments, and Prisma Postgres databases. Production data is not copied into that database. See [Compute branching](/docs/compute/branching).", + }, + { + id: "compliance-tier", + title: "HIPAA, SOC 2, and ISO 27001 on Business", + body: "Prisma Postgres HIPAA, SOC 2 Type II, and ISO 27001 are on Business at $129 a month. HIPAA needs a signed BAA and is not on Free, Starter, or Pro. Supabase lists SOC 2 and ISO 27001 on Team at $599 a month, with HIPAA as a paid add-on. See [HIPAA on the Business plan](/postgres/hipaa).", + }, +]; + +export const PRICING: readonly { + label: string; + supabase: string; + prisma: string; + sources: { supabase: string; prisma: string }; +}[] = [ + { + label: "Free plan", + supabase: + "$0 a month. 500 MB database size per project, 2 active projects. Free projects pause after 1 week of inactivity.", + prisma: + "Prisma Postgres Free is $0. No credit card required. 200,000 operations a month, 2 GB of storage, and 50 databases.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Paid plans", + supabase: + "Pro from $25 a month. Team from $599 a month. Enterprise is custom. Pro and Team include $10 a month in compute credits.", + prisma: "Starter $10, Pro $49, Business $129 a month for Prisma Postgres and Prisma Compute.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Database compute and storage", + supabase: + "Each project runs its own compute instance, billed hourly. Micro is $10 a month. Pro includes 8 GB disk per project, then $0.125 per GB. Branching compute is $0.01344 per branch per hour.", + prisma: + "Starter includes 10 GB, then $2.00 per GB. Pro includes 50 GB, then $1.50 per GB. Business includes 100 GB, then $1.00 per GB. Database usage is operations, not hourly compute.", + sources: { + supabase: "https://supabase.com/pricing", + prisma: "https://www.prisma.io/pricing", + }, + }, + { + label: "Spend controls", + supabase: + "Spend Cap is on by default on Pro. It does not cover Compute, Branching Compute, Read Replica Compute, or Point-in-Time Recovery.", + prisma: "Spend limits on Starter, Pro, and Business.", + sources: { + supabase: "https://supabase.com/docs/guides/platform/cost-control#spend-cap", + prisma: "https://www.prisma.io/pricing", + }, + }, +]; + +export const WRONG_CHOICE = [ + "You want Auth, Storage, Realtime, auto-generated APIs, or Edge Functions as first-party services next to Postgres. Prisma Postgres does not include those. See [Supabase pricing](https://supabase.com/pricing).", + "You want the frontend on a host such as Vercel, with Supabase isolating Auth, Storage, Edge Functions, and the database on a preview branch. A Prisma preview branch can own Compute services and databases; it does not include those Supabase backend products. See [Supabase branching](https://supabase.com/docs/guides/deployment/branching).", + "You need point-in-time recovery today. Prisma Postgres has no point-in-time recovery. Paid plans include daily backups. Supabase sells PITR as a $100 per month add-on per 7 days of retention on [Supabase pricing](https://supabase.com/pricing).", + "You want database compute billed by the hour with a $25 Pro base, and you are fine that the Spend Cap excludes compute. Prisma Postgres bills operations and storage instead.", +]; + +export const FAQ: readonly { question: string; answer: string }[] = [ + { + question: "Do I need Prisma ORM to use Prisma Postgres?", + answer: + "No. Prisma Postgres is PostgreSQL. Any Postgres client works with it, so Prisma ORM is optional. Source: [Prisma Postgres](/docs/postgres), October 2026.", + }, + { + question: "Does Supabase host the frontend?", + answer: + "Supabase Edge Functions run server-side logic next to the database. Full app and frontend hosting remains on a separate platform. Source: [Supabase pricing](https://supabase.com/pricing), October 2026.", + }, + { + question: "Does Prisma Postgres include Auth, Storage, or Realtime?", + answer: + "No. Prisma Postgres is managed PostgreSQL. Auth, Storage, and Realtime are not included; use integrations with other tools. Supabase ships those products with every project. Source: [Prisma Postgres](/docs/postgres) and [Supabase pricing](https://supabase.com/pricing), October 2026.", + }, + { + question: "How do Supabase and Prisma Postgres bill for the database?", + answer: + "Supabase charges an organization plan (Pro from $25 a month, Team from $599) plus compute billed separately per project. Pro and Team include $10 a month in compute credits. Prisma Postgres bills operations and storage, with spend limits on paid plans. Prisma Postgres Free includes 200,000 operations and 2 GB of storage, with no credit card. Source: [Supabase pricing](https://supabase.com/pricing) and [Prisma pricing](/pricing), October 2026.", + }, + { + question: "Which plan includes HIPAA?", + answer: + "Prisma Postgres HIPAA is on the Business plan at $129 a month, with a signed BAA. It is not on Free, Starter, or Pro. Supabase lists HIPAA as a paid add-on on Team ($599 a month) and Enterprise. Source: [HIPAA on the Business plan](/postgres/hipaa) and [Supabase pricing](https://supabase.com/pricing), October 2026.", + }, +]; + +export const TOOLS = { + supabase: { + name: "Supabase", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/supabase/supabase", + programmingLanguage: "TypeScript", + url: "https://supabase.com", + }, + prisma: { + name: "Prisma Postgres", + softwareVersion: "current", + license: "https://www.apache.org/licenses/LICENSE-2.0", + codeRepository: "https://github.com/prisma/prisma", + programmingLanguage: "TypeScript", + url: "https://www.prisma.io/docs/postgres", + }, +} as const; diff --git a/apps/site/src/app/compare/supabase/_sections.tsx b/apps/site/src/app/compare/supabase/_sections.tsx new file mode 100644 index 00000000000..81182810337 --- /dev/null +++ b/apps/site/src/app/compare/supabase/_sections.tsx @@ -0,0 +1,418 @@ +import Link from "next/link"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { TESTIMONIALS } from "@/components/sections/testimonials-data"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + comparisonChooseTable, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; +import { comparisonByline } from "../byline"; +import { comparisonProofCards } from "../proof-cards"; +import { articleReadingStats } from "../reading-time"; +import { CompareTable } from "../compare-table"; +import { ComparisonPricingTable } from "../pricing-table"; +import { QUIET_LINK } from "../table-styles"; +import { renderFaqAnswer } from "@/lib/faq-answer"; +import { + APART, + CHECKED, + CHOOSE_PRISMA, + CHOOSE_SUPABASE, + COMPARE_ROWS, + FAQ, + PRICING, + PRISMA_FITS, + SUPABASE_AUTHOR, + SUPABASE_FITS, + SUPABASE_TITLE, + SUPABASE_UPDATED, + WRONG_CHOICE, +} from "./_content"; + +const INLINE = + "font-medium text-foreground underline underline-offset-4 decoration-black/35 transition-colors hover:decoration-black/60"; + +/** + * One rhythm for the whole body: 56px between sections and 20px under every h2. + * Prose margins are zeroed so these gaps are the only spacing, and every + * paragraph and list item shares one size, line height and colour. Code spans from prose and from + * `renderFaqAnswer` share one treatment, and a span that wraps keeps its padding on both lines. + */ +const BODY = + "flex flex-col gap-14 [&_:is(h2,h3)]:my-0 [&_:is(h2,h3)]:text-primary [&_:is(p,ul,ol,li,table,figure,img)]:my-0 [&_:is(p,li)]:text-base [&_:is(p,li)]:leading-7 [&_:is(p,li)]:text-foreground [&_code]:box-decoration-clone [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-[family-name:var(--font-code)] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_table_code]:text-[13px] [&_blockquote]:my-0 [&_blockquote]:border-0 [&_blockquote]:pl-0 [&_blockquote]:font-normal [&_blockquote]:not-italic [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"; +const SECTION = "flex flex-col gap-5"; +const LIST = "list-disc space-y-2 pl-5 marker:text-black/25"; +const STEPS = "list-decimal space-y-2 pl-5 marker:text-muted-foreground"; +const CHOOSE_LABEL = "font-heading text-lg leading-snug font-medium"; + +const PROOF = TESTIMONIALS.filter( + (item) => item.name === "Ron Cohen" || item.name === "Omar López", +); + +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +const SOURCES = [ + { + product: "Supabase", + links: [ + { label: "Supabase pricing", href: "https://supabase.com/pricing" }, + { + label: "Spend Cap", + href: "https://supabase.com/docs/guides/platform/cost-control#spend-cap", + }, + { label: "Branching", href: "https://supabase.com/docs/guides/deployment/branching" }, + { + label: "Apache License 2.0", + href: "https://github.com/supabase/supabase/blob/master/LICENSE", + }, + ], + }, + { + product: "Prisma Postgres, Prisma ORM, and Prisma Compute", + links: [ + { label: "Prisma Postgres", href: "/docs/postgres" }, + { label: "Prisma ORM docs", href: "/docs/orm" }, + { label: "Prisma Compute", href: "/docs/compute" }, + { label: "Compute limitations", href: "/docs/compute/limitations" }, + { label: "Compute branching", href: "/docs/compute/branching" }, + { label: "HIPAA on the Business plan", href: "/postgres/hipaa" }, + { + label: "Migrate from Supabase", + href: "/docs/guides/switch-to-prisma-postgres/from-supabase", + }, + { + label: "existing-database guide", + href: "/docs/prisma-orm/add-to-existing-project/postgresql", + }, + { label: "Apache License 2.0", href: "https://github.com/prisma/prisma/blob/main/LICENSE" }, + { label: "Prisma Postgres pricing", href: "/pricing" }, + ], + }, +] as const; + +function SourceLink({ href, children }: { href: string; children: string }) { + return href.startsWith("/") ? ( + + {children} + + ) : ( + + {children} + + ); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +function buildSupabasePage(minutes: number) { + return [ +
+

+ {SUPABASE_TITLE} +

+

+ Pick{" "} + + Prisma Postgres + {" "} + when you want managed PostgreSQL with{" "} + + Prisma ORM + {" "} + optional, and{" "} + + Prisma Compute + {" "} + hosting the TypeScript app as HTTP services in the same Prisma project. Pick{" "} + + Supabase + {" "} + when you want Postgres plus Auth, Storage, Realtime, auto-generated APIs, and Edge + Functions, with the UI on a separate host. +

+ {comparisonHeroCtas("postgres")} + {comparisonByline({ author: SUPABASE_AUTHOR, updated: SUPABASE_UPDATED, minutes })} + {comparisonDisclosure({ + competitor: "Supabase", + checked: CHECKED, + prismaProduct: "Prisma Postgres", + })} +
, +
+
+ + When should I choose Supabase or Prisma Postgres? + + {comparisonChooseTable({ + competitor: "Supabase", + prisma: "Prisma Postgres", + competitorItems: CHOOSE_SUPABASE, + prismaItems: CHOOSE_PRISMA, + })} +
+ +
+ + How do Supabase and Prisma Postgres compare? + +

+ Supabase and Prisma Postgres are both managed PostgreSQL. Supabase adds Auth, Storage, + Realtime, APIs, and Edge Functions around the database. Prisma Postgres sits next to an + optional ORM and Prisma Compute. Columns are Supabase, then Prisma Postgres. +

+ ({ + label: row.feature, + cells: [renderFaqAnswer(row.supabase), renderFaqAnswer(row.prisma)], + }))} + /> +
+ +
+ + Where does each one fit? + +
+
+

Supabase

+

+ Supabase is a backend platform around Postgres. Its{" "} + + pricing page + {" "} + lists Auth, Storage, Realtime, and Edge Functions with every project. The UI stays on + a separate host. +

+
    + {SUPABASE_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Prisma Postgres

+

+ Prisma Postgres fits a team that wants PostgreSQL next to the TypeScript app.{" "} + + Prisma ORM + {" "} + is optional.{" "} + + Prisma Compute + {" "} + is separate app hosting in the same Prisma project. +

+
    + {PRISMA_FITS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+ +
+ + {"What does Prisma Postgres do that Supabase doesn't?"} + +
+ {APART.map((item) => ( +
+ + {item.title} + +

{renderFaqAnswer(item.body)}

+
+ ))} +
+
+ +
+ + How much do Supabase and Prisma Postgres cost? + + ({ + label: row.label, + cells: [renderFaqAnswer(row.supabase), renderFaqAnswer(row.prisma)], + }))} + note={ + <> + Prisma Postgres has a Free plan: $0 a month for 200,000 operations, 2 GB of storage, + and 50 databases. No credit card required. The{" "} + + pricing page + {" "} + lists the paid plans. Supabase Free includes 500 MB of database size per project and + pauses after 1 week of inactivity ( + + Supabase pricing + + , {CHECKED}). + + } + /> +
+ +
+ + When is Prisma Postgres the wrong choice? + +
    + {WRONG_CHOICE.map((item) => ( +
  • {renderFaqAnswer(item)}
  • + ))} +
+
+ +
+ + How do I switch from Supabase to Prisma Postgres? + +
    +
  1. + Create an empty Prisma Postgres database. Prisma ORM is optional. The{" "} + + migrate from Supabase + {" "} + guide uses pg_dump and pg_restore over the direct connection. +
  2. +
  3. + Dump the PostgreSQL application data you own, then restore into Prisma Postgres. The + dump copies schemas and data. It does not migrate Auth users, Storage objects, Realtime + configuration, or Edge Functions. +
  4. +
  5. + Point the app at the Prisma Postgres connection string. Host the HTTP app on Prisma + Compute, or keep the frontend host you already use. Replace any Auth, Storage, or + Realtime calls before you switch production traffic. +
  6. +
+
+ +
+ + How do I switch from Prisma Postgres to Supabase? + +
    +
  1. + Create a Supabase project and dump Prisma Postgres with pg_dump over the + direct connection, then restore into Supabase. Both sides are PostgreSQL. +
  2. +
  3. + Point the app at the Supabase connection string. Keep using SQL or an ORM; Supabase has + no first-party ORM. +
  4. +
  5. + Publish the UI on a separate host. Add Auth, Storage, Realtime, or Edge Functions if you + need those products. Prisma Postgres stays in place until you stop using it. +
  6. +
+
+ +
+ + What do teams say about Prisma Postgres? + +

+ Two teams on what Prisma ORM and Prisma Postgres do for them. +

+ {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )} +
+ +
+ + Frequently asked questions + + + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
, + comparisonClosingCta("postgres"), +
+

+ Sources +

+

+ Every competitor figure on this page comes from these pages, checked {CHECKED}. +

+
+ {SOURCES.map((group) => ( +
+

{group.product}

+
    + {group.links.map((link) => ( +
  • + {link.label} +
  • + ))} +
+
+ ))} +
+
, + ]; +} + +const SUPABASE_READING = articleReadingStats(buildSupabasePage(1)); + +export function getSupabaseReadingStats() { + return articleReadingStats(buildSupabasePage(SUPABASE_READING.minutes)); +} + +/** Build the shared content tree for the HTML page and its Markdown rendition. */ +export function getSupabasePageContent() { + return buildSupabasePage(SUPABASE_READING.minutes); +} + +/** Render the comparison in the guide article frame. */ +export function SupabasePage() { + return {getSupabasePageContent()}; +} diff --git a/apps/site/src/app/compare/supabase/facts.ts b/apps/site/src/app/compare/supabase/facts.ts new file mode 100644 index 00000000000..d7285dd8e03 --- /dev/null +++ b/apps/site/src/app/compare/supabase/facts.ts @@ -0,0 +1,86 @@ +import { + COMPARE_ROWS, + PRICING, + SUPABASE_PATH, + SUPABASE_PUBLISHED, + SUPABASE_UPDATED, + TOOLS, +} from "./_content"; +import { getSupabaseReadingStats } from "./_sections"; + +export type FactValue = { + value: string | null; + source: string; + dateChecked: string; +}; + +/** Machine-readable feature and pricing cells with a source on every value. */ +export function getSupabaseFacts() { + const dateChecked = SUPABASE_UPDATED.iso; + return { + path: SUPABASE_PATH, + dateChecked, + tools: TOOLS, + features: COMPARE_ROWS.map((row) => ({ + feature: row.feature, + supabase: { + value: row.supabase, + source: row.sources.supabase, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + pricing: PRICING.map((row) => ({ + label: row.label, + supabase: { + value: row.supabase, + source: row.sources.supabase, + dateChecked, + } satisfies FactValue, + prisma: { + value: row.prisma, + source: row.sources.prisma, + dateChecked, + } satisfies FactValue, + })), + }; +} + +export function getSupabaseAboutEntities() { + return [ + { + "@type": "SoftwareApplication", + name: TOOLS.supabase.name, + softwareVersion: TOOLS.supabase.softwareVersion, + license: TOOLS.supabase.license, + codeRepository: TOOLS.supabase.codeRepository, + programmingLanguage: TOOLS.supabase.programmingLanguage, + url: TOOLS.supabase.url, + }, + { + "@type": "SoftwareApplication", + name: TOOLS.prisma.name, + softwareVersion: TOOLS.prisma.softwareVersion, + license: TOOLS.prisma.license, + codeRepository: TOOLS.prisma.codeRepository, + programmingLanguage: TOOLS.prisma.programmingLanguage, + url: TOOLS.prisma.url, + }, + ]; +} + +/** Article JSON-LD fields that sit on top of the shared comparison graph. */ +export function getSupabaseArticleFields() { + const { wordCount, timeRequired } = getSupabaseReadingStats(); + return { + datePublished: SUPABASE_PUBLISHED.iso, + dateModified: SUPABASE_UPDATED.iso, + wordCount, + timeRequired, + about: getSupabaseAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/supabase/page.tsx b/apps/site/src/app/compare/supabase/page.tsx new file mode 100644 index 00000000000..51e541fc50b --- /dev/null +++ b/apps/site/src/app/compare/supabase/page.tsx @@ -0,0 +1,59 @@ +import localFont from "next/font/local"; +import { JsonLd } from "@prisma-docs/ui/components/json-ld"; +import { + getComparisonMetadata, + getComparisonOgImageUrl, + getComparisonStructuredData, +} from "@/lib/comparison-metadata"; +import { createFaqStructuredData } from "@/lib/structured-data"; +import { getBaseUrl } from "@/lib/url"; +import { comparisonAuthorPerson } from "../byline"; +import { FAQ, SUPABASE_AUTHOR, SUPABASE_PATH, SUPABASE_TITLE } from "./_content"; +import { getSupabaseArticleFields } from "./facts"; +import { SupabasePage } from "./_sections"; + +/** + * The docs' code face. Shared with the Kysely comparison so both pages load + * one copy of the unbracketed Mona Sans Mono file. + */ +const monaSansMono = localFont({ + src: "../kysely/mona-sans-mono.woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + +export const metadata = getComparisonMetadata("supabase"); + +const faqStructuredData = createFaqStructuredData(SUPABASE_PATH, [...FAQ], SUPABASE_TITLE); + +/** The shared comparison graph, with this page's author, dates, image, and tool entities. */ +function articleStructuredData() { + const base = getComparisonStructuredData("supabase"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + ...getSupabaseArticleFields(), + image: getComparisonOgImageUrl("supabase"), + author: comparisonAuthorPerson(SUPABASE_AUTHOR, getBaseUrl()), + }, + ...rest, + ], + }; +} + +/** Render the comparison and its article, breadcrumb and FAQ structured data. */ +export default function Page() { + return ( + <> + + +
+ +
+ + ); +} diff --git a/apps/site/src/app/compare/tab-code-copy.tsx b/apps/site/src/app/compare/tab-code-copy.tsx new file mode 100644 index 00000000000..0165b3b6f77 --- /dev/null +++ b/apps/site/src/app/compare/tab-code-copy.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, Clipboard } from "lucide-react"; +import { COPIED_VISIBLE_MS } from "./copied"; + +function tabsRoot(from: HTMLElement) { + let node: HTMLElement | null = from; + while (node) { + if (node.querySelector('[role="tablist"]') && node.querySelector('[role="tabpanel"]')) { + return node; + } + node = node.parentElement; + } + return null; +} + +/** Copies the open tab's snippet. Sits in the tab bar so it never covers the code. */ +export function TabCodeCopy({ label }: { label: string }) { + const [copied, setCopied] = useState(false); + const timer = useRef>(undefined); + + useEffect(() => () => clearTimeout(timer.current), []); + + async function copy(event: React.MouseEvent) { + const root = tabsRoot(event.currentTarget); + const panel = + root?.querySelector('[role="tabpanel"]:not([hidden])') ?? + root?.querySelector('[role="tabpanel"][data-state="active"]'); + const text = panel?.querySelector("pre")?.textContent ?? ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + } catch { + return; + } + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), COPIED_VISIBLE_MS); + } + + return ( + + ); +} diff --git a/apps/site/src/app/compare/table-scroll-region.tsx b/apps/site/src/app/compare/table-scroll-region.tsx new file mode 100644 index 00000000000..9444e493415 --- /dev/null +++ b/apps/site/src/app/compare/table-scroll-region.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; + +/** + * Keyboard-scrollable from `sm` up, where the table is wide and `overflow-x-auto`. + * Below 640px rows stack, so a tabIndex would be an empty stop. + */ +export function TableScrollRegion({ + labelledBy, + className, + style, + children, +}: { + labelledBy: string; + className: string; + style?: CSSProperties; + children: ReactNode; +}) { + const [wide, setWide] = useState(false); + + useEffect(() => { + const media = window.matchMedia("(min-width: 640px)"); + const update = () => setWide(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/site/src/app/compare/table-styles.ts b/apps/site/src/app/compare/table-styles.ts new file mode 100644 index 00000000000..2d0aab0a46a --- /dev/null +++ b/apps/site/src/app/compare/table-styles.ts @@ -0,0 +1,33 @@ +/** Table cell padding comes from `.answer-prose`; these set the frame, type and column widths. */ +export const TABLE_FRAME = + "compare-table-frame relative overflow-x-auto max-sm:overflow-visible rounded-xl border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +/** + * Below 640px each row becomes a labelled card (`display: block` + `data-label`). + * Pair with `data-label` on every `td`. Unlayered CSS in `globals.css` owns the + * reflow so it can override `.answer-prose table { min-width: 720px }`. + */ +export const TABLE_STACK = "compare-stack"; + +export const TH_COL = "bg-muted/50 text-sm font-semibold leading-6 text-primary"; +export const TH_ROW = "bg-background text-sm font-semibold leading-6 text-primary"; +export const TD = "text-sm leading-6 text-foreground"; +export const TABLE_ROW = "border-t border-border"; + +/** + * A shadow at the right edge while more columns sit off-screen. The `local` + * cover scrolls with the table and hides the shadow once the last column is in view. + * Hidden on phones, where rows stack instead of scrolling sideways. + */ +export const SCROLL_HINT = { + background: + "linear-gradient(to right, transparent, var(--background) 70%) 100% 0 / 2.5rem 100% no-repeat local, radial-gradient(farthest-side at 100% 50%, rgb(0 0 0 / 0.12), transparent) 100% 0 / 0.875rem 100% no-repeat scroll, var(--background)", +}; + +/** Keeps each row's label in view while the table scrolls sideways from `sm` up. */ +export const STICKY = + "max-sm:static max-sm:bg-transparent max-sm:before:hidden max-sm:after:hidden sticky left-0 z-10 before:absolute before:inset-y-0 before:-left-1 before:w-1 before:bg-inherit after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-border"; + +export const QUIET_NOTE = "max-w-[32rem] !text-sm !leading-6 !text-muted-foreground"; +export const QUIET_LINK = + "underline underline-offset-4 decoration-black/35 transition-colors hover:text-foreground hover:decoration-black/50"; diff --git a/apps/site/src/app/globals.css b/apps/site/src/app/globals.css index b31be996263..d0251626afe 100644 --- a/apps/site/src/app/globals.css +++ b/apps/site/src/app/globals.css @@ -525,7 +525,7 @@ --destructive: #d02344; /* prism-red-600 */ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --ring: oklch(0.45 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); @@ -578,7 +578,7 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply border-border outline-ring; } body { @apply bg-background text-foreground; @@ -618,6 +618,89 @@ html { } } +/* Skip link: first Tab stop, visually hidden until focused. */ +.skip-link { + position: absolute; + left: 0.75rem; + top: 0.75rem; + z-index: 100; + border-radius: 0.375rem; + background: var(--foreground); + color: var(--background); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + transform: translateY(-200%); +} +.skip-link:focus { + transform: none; +} + +/* Themed thin scrollbar on comparison table regions. */ +.compare-table-frame { + scrollbar-width: thin; + scrollbar-color: rgb(0 0 0 / 0.28) transparent; +} +.compare-table-frame::-webkit-scrollbar { + height: 8px; +} +.compare-table-frame::-webkit-scrollbar-thumb { + background: rgb(0 0 0 / 0.28); + border-radius: 99px; +} + +/* + * Stacked comparison rows below 640px. Unlayered so it beats + * `.answer-prose table { min-width: 720px }` and any inline minWidth. + */ +.answer-prose .compare-stack table { + min-width: var(--compare-table-min-width, 40rem); +} +@media (max-width: 639px) { + .answer-prose .compare-stack table { + display: block; + min-width: 0 !important; + width: 100%; + } + .answer-prose .compare-stack thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + .answer-prose .compare-stack tbody, + .answer-prose .compare-stack tr, + .answer-prose .compare-stack th, + .answer-prose .compare-stack td { + display: block; + width: 100%; + } + .answer-prose .compare-stack tr { + padding: 1rem 1rem 0.75rem; + } + .answer-prose .compare-stack :is(th, td):first-child { + width: auto; + } + .answer-prose .compare-stack td { + padding-top: 0.5rem; + padding-bottom: 0.75rem; + } + .answer-prose .compare-stack td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.25rem; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.5rem; + color: var(--primary); + } +} + /* Brand rule: headings are always the heading face at Medium (the variable axis midpoint between Regular and SemiBold — 600 read too heavy) and natural letter-spacing — the bold pass from client feedback (2026-07): diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index 41bfa3a711f..e6564661dbe 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -115,13 +115,18 @@ export default function RootLayout({ + + Skip to content + {/* The campaign landing pages under /lp carry their own header and footer (app/lp/layout.tsx); SiteChrome drops the site's for them. */}
-
{children}
+
+ {children} +