From 411daba023614d6d30f364a12fbc3f2559802756 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 11:10:26 +0000 Subject: [PATCH 01/52] site: lay out comparison pages with the docs template Comparison routes now read as articles in the docs-template document chrome. Copy, Markdown renditions, and JSON-LD stay in place. Co-authored-by: rensburg --- apps/site/src/app/compare/compare-list.tsx | 324 ------- .../site/src/app/compare/convex/_sections.tsx | 802 ++++++++--------- apps/site/src/app/compare/convex/page.tsx | 5 +- apps/site/src/app/compare/document-chrome.tsx | 361 ++++++++ apps/site/src/app/compare/document.tsx | 111 +++ .../site/src/app/compare/kysely/_sections.tsx | 819 +++++++++-------- apps/site/src/app/compare/kysely/page.tsx | 5 +- .../src/app/compare/mongoose/_sections.tsx | 829 +++++++++--------- apps/site/src/app/compare/mongoose/page.tsx | 5 +- apps/site/src/app/compare/page.tsx | 122 +-- apps/site/src/app/layout.tsx | 2 +- apps/site/src/components/site-chrome.tsx | 24 +- 12 files changed, 1757 insertions(+), 1652 deletions(-) delete mode 100644 apps/site/src/app/compare/compare-list.tsx create mode 100644 apps/site/src/app/compare/document-chrome.tsx create mode 100644 apps/site/src/app/compare/document.tsx diff --git a/apps/site/src/app/compare/compare-list.tsx b/apps/site/src/app/compare/compare-list.tsx deleted file mode 100644 index 9adf33180b..0000000000 --- a/apps/site/src/app/compare/compare-list.tsx +++ /dev/null @@ -1,324 +0,0 @@ -"use client"; - -import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; -import Link from "next/link"; -import { ArrowRightBold, ChevronsUpDown, Search, X } from "@/components/icons/forma"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { - COMPARISON_CATEGORIES, - COMPARISONS, - type ComparisonCategory, - type ComparisonEntry, -} from "./comparisons"; - -type CategoryFilter = ComparisonCategory | "all"; -type SortKey = "name-asc" | "name-desc"; - -const SORT_OPTIONS: readonly { value: SortKey; label: string }[] = [ - { value: "name-asc", label: "Name A–Z" }, - { value: "name-desc", label: "Name Z–A" }, -]; - -type CompareContextValue = { - query: string; - setQuery: (value: string) => void; - filtered: readonly ComparisonEntry[]; -}; - -const CompareContext = createContext(null); - -function useCompare() { - const value = useContext(CompareContext); - if (!value) { - throw new Error("Compare controls must render inside CompareState"); - } - return value; -} - -export function CompareState({ children }: { children: ReactNode }) { - const [query, setQuery] = useState(""); - const filtered = useMemo(() => COMPARISONS.filter((entry) => matches(entry, query)), [query]); - return ( - - {children} - - ); -} - -function CompareSearch() { - const { query, setQuery } = useCompare(); - - return ( -
- - - setQuery(event.target.value)} - placeholder="Search comparisons" - autoComplete="off" - className="h-9 rounded-lg border-black/[0.09] bg-white pl-9 pr-9 text-sm shadow-none md:text-sm [&::-webkit-search-cancel-button]:appearance-none" - /> - {query ? ( - - ) : null} -
- ); -} - -function CompareSort({ value, onChange }: { value: SortKey; onChange: (sort: SortKey) => void }) { - return ( - - ); -} - -function FilterGroup({ label, children }: { label: string; children: ReactNode }) { - return ( -
-

- {label} -

-
{children}
-
- ); -} - -function FilterRow({ - active, - onClick, - count, - dot, - children, -}: { - active: boolean; - onClick: () => void; - count: number; - dot?: string; - children: ReactNode; -}) { - return ( - - ); -} - -function CategoryPill({ - active, - onClick, - count, - dot, - children, -}: { - active: boolean; - onClick: () => void; - count: number; - dot?: string; - children: ReactNode; -}) { - return ( - - ); -} - -function matches(entry: ComparisonEntry, query: string) { - const haystack = `${entry.competitor} ${entry.title} ${entry.description}`.toLowerCase(); - return query - .toLowerCase() - .split(/\s+/) - .filter(Boolean) - .every((term) => haystack.includes(term)); -} - -export function CompareRows() { - const { query, filtered } = useCompare(); - const [category, setCategory] = useState("all"); - const [sort, setSort] = useState("name-asc"); - const counts = useMemo(() => { - const byCategory = Object.fromEntries( - COMPARISON_CATEGORIES.map((item) => [ - item.id, - COMPARISONS.filter((entry) => entry.category === item.id).length, - ]), - ) as Record; - return { all: COMPARISONS.length, ...byCategory }; - }, []); - const visibleCategories = COMPARISON_CATEGORIES.filter((item) => counts[item.id] > 0); - const visible = useMemo(() => { - const inCategory = - category === "all" ? filtered : filtered.filter((entry) => entry.category === category); - return [...inCategory].sort((a, b) => { - const order = a.competitor.localeCompare(b.competitor); - return sort === "name-desc" ? -order : order; - }); - }, [filtered, category, sort]); - const countLabel = visible.length === 1 ? "comparison" : "comparisons"; - const categoryHasEntries = category === "all" ? COMPARISONS.length > 0 : counts[category] > 0; - const showSearchEmpty = visible.length === 0 && query.trim() !== "" && categoryHasEntries; - - return ( -
- - -
-
- setCategory("all")} - count={counts.all} - > - All - - {visibleCategories.map((item) => ( - setCategory(item.id)} - count={counts[item.id]} - dot={item.dot} - > - {item.label} - - ))} -
- -
-

- {visible.length}{" "} - {countLabel} -

-
-
- -
- -
-
- - {showSearchEmpty ? ( -

No comparisons match that search.

- ) : null} - - {visible.length > 0 ? ( -
    - {visible.map((entry) => ( -
  1. - -

    - {entry.competitor} -

    - - - {entry.title} - - - {entry.description} - - - - -
  2. - ))} -
- ) : null} -
-
- ); -} diff --git a/apps/site/src/app/compare/convex/_sections.tsx b/apps/site/src/app/compare/convex/_sections.tsx index b8bfc40a9d..d89aa5f1b8 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -1,16 +1,13 @@ import Link from "next/link"; +import { Info } from "lucide-react"; 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 { ArticleActions } from "../document-chrome"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -36,452 +33,427 @@ const PROOF = TESTIMONIALS.filter( export function getConvexPageContent() { 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 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. +

+ +

+ + Read the Postgres docs + {" "} + + See which fits + +

+
+
+
+
+
+

+ Choose Prisma Postgres if +

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

+ Choose Convex if +

+
    + {CHOOSE_CONVEX.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
- -
-
- Compare -

- {CONVEX_TITLE} -

-

- Pick{" "} - - Prisma Postgres - {" "} - when you want a standard SQL database. It is PostgreSQL,{" "} +

+ +
+
+

+ 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}. +

+
+
+ +
+
+
+

+ Where Convex works well +

+

+ Convex is a reactive database. Its{" "} + + overview + {" "} + describes TypeScript queries that rerun when their data changes, and a client + library that subscribes over a WebSocket. +

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

+ Where Prisma Postgres fits better +

+

+ Prisma Postgres fits a team that wants PostgreSQL.{" "} Prisma ORM {" "} - is optional, and{" "} + is optional.{" "} 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. + is separate app hosting.

-

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

-
- - Read the Postgres docs - - - See which fits - -
+
    + {PRISMA_FITS.map((item) => ( +
  • + {item} +
  • + ))} +
-
-
+ -
-
-
-

- Choose Prisma Postgres if +
+
+

+ What sets Prisma Postgres apart

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

    {item.title}

    +

    + {item.body} +

    +
))} - -

-
-

- Choose Convex if -

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

- 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}. -

-
-
+ -
-
-
-

- Where Convex works well +
+
+

+ Pricing

-

- Convex is a reactive database. Its{" "} - - overview - {" "} - describes TypeScript queries that rerun when their data changes, and a client library - that subscribes over a WebSocket. -

-
    - {CONVEX_FITS.map((item) => ( -
  • - {item} -
  • - ))} -
+
+
+

Convex

+

+ Free and Starter are $0, then pay as you go, and list 1 to 6 developers. The + resource table includes 0.5 GB of database storage. Professional is $25 per + developer per month. Business and Enterprise have a $2,500 monthly minimum ( + + Convex pricing + + , {CHECKED}). +

+
+
+

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. +

+
+
-
-

- Where Prisma Postgres fits better +

+ +
+
+

+ When Prisma Postgres is the wrong choice

-

- Prisma Postgres fits a team that wants PostgreSQL.{" "} - - Prisma ORM - {" "} - is optional.{" "} - - Prisma Compute - {" "} - is separate app hosting. -

-
    - {PRISMA_FITS.map((item) => ( -
  • - {item} +
      + {WRONG_CHOICE.map((item) => ( +
    • + {renderFaqAnswer(item)}
    • ))}
-

-
- -
-
-

- What sets Prisma Postgres apart -

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

{item.title}

-

- {item.body} -

-
- ))} -
-
-
+ -
-
-

- Pricing -

-
-
-

Convex

-

- Free and Starter are $0, then pay as you go, and list 1 to 6 developers. The - resource table includes 0.5 GB of database storage. Professional is $25 per - developer per month. Business and Enterprise have a $2,500 monthly minimum ( - - Convex pricing - - , {CHECKED}). -

+
+
+
+

+ 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. +
-
-

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. -

+
+

+ 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. +
-
-
- -
-
-

- When Prisma Postgres is the wrong choice -

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

- Move from Convex to Prisma Postgres +
+
+

+ What teams on Prisma say

-
    -
  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. -
+

+ 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} +
+
+ ))} +
-
-

- Move from Prisma Postgres to Convex +

+ +
+
+

+ Frequently asked questions

-
    -
  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. -
+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
-

-
+ -
-
-

- What teams on Prisma say +
+

+ Start with the Free plan

-

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

+ Free to start, no credit card required.

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-

-
- -
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
-
-
- - +
    +
  • Prisma Postgres Free includes 200,000 operations a month
  • +
  • Prisma ORM is optional with Prisma Postgres
  • +
  • The pricing page does not list a per-developer fee
  • +
+

+ + Get started free + + + Talk to us + +

+ + ); } diff --git a/apps/site/src/app/compare/convex/page.tsx b/apps/site/src/app/compare/convex/page.tsx index f74bf86ce0..95d9f59d08 100644 --- a/apps/site/src/app/compare/convex/page.tsx +++ b/apps/site/src/app/compare/convex/page.tsx @@ -2,6 +2,7 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { CONVEX_PATH, CONVEX_TITLE, FAQ } from "./_content"; +import { CompareDocument } from "../document"; import { ConvexPage } from "./_sections"; export const metadata = getComparisonMetadata("convex"); @@ -14,7 +15,9 @@ export default function Page() { <> - + + + ); } diff --git a/apps/site/src/app/compare/document-chrome.tsx b/apps/site/src/app/compare/document-chrome.tsx new file mode 100644 index 0000000000..dcdf58a477 --- /dev/null +++ b/apps/site/src/app/compare/document-chrome.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchContext } from "fumadocs-ui/contexts/search"; +import { ArrowLeft, ArrowRight, ChevronLeft, Copy, Menu, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { COMPARISONS, type ComparisonSlug } from "./comparisons"; + +export type CompareSlug = ComparisonSlug | "index"; + +const NAV_LINK = + "flex rounded-lg px-2 py-1.5 font-medium transition-colors text-foreground/75 hover:bg-black/[0.04] hover:text-foreground"; + +const NAV_CURRENT = + "spectrum-border spectrum-border-on flex rounded-lg px-2 py-1.5 font-medium text-foreground"; + +export function CompareNav({ + current, + onNavigate, +}: { + current: CompareSlug; + onNavigate?: () => void; +}) { + return ( +
+ + + All comparisons + +
    + {COMPARISONS.map((entry) => { + const active = entry.slug === current; + return ( +
  • + + {entry.competitor} + +
  • + ); + })} +
+
+ ); +} + +function SearchButton({ compact = false }: { compact?: boolean }) { + const { setOpenSearch } = useSearchContext(); + + if (compact) { + return ( + + ); + } + + return ( + + ); +} + +export function CompareHeader({ current }: { current: CompareSlug }) { + const [open, setOpen] = useState(false); + + return ( +
+
+
+ + {/* The document header uses the same mark as the docs template. */} + Prisma + + + / + + + compare + +
+
+ + + + + + + + + + Comparisons + + + + +
+
+
+ ); +} + +export function CompareToc({ items }: { items: readonly { id: string; label: string }[] }) { + const [active, setActive] = useState(items[0]?.id ?? ""); + + useEffect(() => { + const nodes = items + .map((item) => document.getElementById(item.id)) + .filter((node): node is HTMLElement => node !== null); + if (nodes.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + const next = visible[0]?.target.id; + if (next) setActive(next); + }, + { rootMargin: "-96px 0px -65% 0px", threshold: [0, 1] }, + ); + for (const node of nodes) observer.observe(node); + return () => observer.disconnect(); + }, [items]); + + return ( + + ); +} + +export function ArticleActions({ + markdownPath, + sourcePath, +}: { + markdownPath: string; + sourcePath: string; +}) { + const [copied, setCopied] = useState(false); + const [origin, setOrigin] = useState("https://www.prisma.io"); + + useEffect(() => { + setOrigin(window.location.origin); + }, []); + + const markdownUrl = `${origin}${markdownPath}.md`; + const prompt = `Read ${markdownUrl}, I want to ask questions about it.`; + const tools = [ + { + label: "Open in ChatGPT", + href: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`, + icon: , + }, + { + label: "Open in Claude", + href: `https://claude.ai/new?q=${encodeURIComponent(prompt)}`, + icon: , + }, + { + label: "Open in GitHub", + href: `https://github.com/prisma/web/blob/main/${sourcePath}`, + icon: , + }, + ]; + + return ( +
+
+ Open in +
+ {tools.map((tool) => ( + + ))} +
+
+ +
+ ); +} + +const PAGER = + "group flex flex-col rounded-xl border border-black/[0.06] bg-gradient-to-br from-prism-cyan-50/50 via-card to-card p-5 shadow-[0_1px_2px_rgba(21,21,21,0.04)] transition-[box-shadow,border-color] hover:border-black/10 hover:shadow-[0_1px_2px_rgba(21,21,21,0.04),0_14px_28px_-20px_rgba(21,21,21,0.25)]"; + +export function ArticlePager({ + previous, + next, +}: { + previous?: { href: string; label: string }; + next?: { href: string; label: string }; +}) { + if (!previous && !next) return null; + return ( +
+ +
+ ); +} + +function GithubMark() { + return ( + + + + ); +} + +function DiscordMark() { + return ( + + + + ); +} + +function ChatGptMark() { + return ( + + + + ); +} + +function ClaudeMark() { + return ( + + + + ); +} diff --git a/apps/site/src/app/compare/document.tsx b/apps/site/src/app/compare/document.tsx new file mode 100644 index 0000000000..7970238baa --- /dev/null +++ b/apps/site/src/app/compare/document.tsx @@ -0,0 +1,111 @@ +import type { ReactNode } from "react"; +import { Texture } from "@/components/brand/texture"; +import { cn } from "@/lib/utils"; +import { COMPARISONS, type ComparisonSlug } from "./comparisons"; +import { + ArticlePager, + CompareHeader, + CompareNav, + CompareToc, + type CompareSlug, +} from "./document-chrome"; + +const TOC: Record = { + kysely: [ + { id: "choose", label: "Choose Prisma ORM if" }, + { id: "choose-kysely", label: "Choose Kysely if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-kysely", label: "Where Kysely works well" }, + { id: "where-prisma", label: "Where Prisma ORM fits better" }, + { id: "apart", label: "What sets Prisma ORM apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, + { id: "migrate", label: "Move from Kysely to Prisma ORM" }, + { id: "migrate-back", label: "Move from Prisma ORM to Kysely" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Ready to build with Prisma?" }, + ], + mongoose: [ + { id: "choose", label: "Choose Prisma ORM if" }, + { id: "choose-mongoose", label: "Choose Mongoose if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-mongoose", label: "Where Mongoose works well" }, + { id: "where-prisma", label: "Where Prisma ORM fits better" }, + { id: "apart", label: "What sets Prisma ORM apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, + { id: "migrate", label: "Move from Mongoose to Prisma ORM" }, + { id: "migrate-back", label: "Move from Prisma ORM to Mongoose" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Start with the Free plan" }, + ], + convex: [ + { id: "choose", label: "Choose Prisma Postgres if" }, + { id: "choose-convex", label: "Choose Convex if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-convex", label: "Where Convex works well" }, + { id: "where-prisma", label: "Where Prisma Postgres fits better" }, + { id: "apart", label: "What sets Prisma Postgres apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma Postgres is the wrong choice" }, + { id: "migrate", label: "Move from Convex to Prisma Postgres" }, + { id: "migrate-back", label: "Move from Prisma Postgres to Convex" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Start with the Free plan" }, + ], +}; + +const ORDER = ["index", ...COMPARISONS.map((entry) => entry.slug)] as const; + +function neighbor(slug: CompareSlug, direction: -1 | 1) { + const index = ORDER.indexOf(slug); + const next = ORDER[index + direction]; + if (!next) return undefined; + if (next === "index") return { href: "/compare", label: "All comparisons" }; + const entry = COMPARISONS.find((item) => item.slug === next); + return entry ? { href: `/compare/${entry.slug}`, label: entry.competitor } : undefined; +} + +/** + * Document page for /compare, matching the docs template: textured ground, + * section header, sidebar, reading card, and a table of contents on articles. + */ +export function CompareDocument({ slug, children }: { slug: CompareSlug; children: ReactNode }) { + const toc = slug === "index" ? undefined : TOC[slug]; + + return ( +
+
+ +
+ +
+
+ +
+
+ {children} + +
+
+ {toc ? : null} +
+
+
+ ); +} diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 5c451b57dd..1e79a2ad75 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,16 +1,13 @@ import Link from "next/link"; +import { Info } from "lucide-react"; 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 { ArticleActions } from "../document-chrome"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -36,456 +33,432 @@ const PROOF = TESTIMONIALS.filter( export function getKyselyPageContent() { return ( <> -
-
-
-
-
+
+
+

+ {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{" "} + + 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. +

+ +

+ + Read the ORM docs + {" "} + + See which fits + +

+
+
+
+
+
+

+ Choose Prisma ORM if +

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

+ Choose Kysely if +

+
    + {CHOOSE_KYSELY.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
- -
-
- 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{" "} - - Kysely +

+ +
+ +
+ +
+
+
+

+ Where Kysely works well +

+

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

-

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

    + {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 + {" "} + describe that file as the contract.

-
- - Read the ORM docs - - - See which fits - -
+
    + {PRISMA_FITS.map((item) => ( +
  • + {item} +
  • + ))} +
-
-
+ -
-
-
-

- Choose Prisma ORM if -

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

- Choose Kysely if +
+
+

+ What sets Prisma ORM apart

-
    - {CHOOSE_KYSELY.map((item) => ( -
  • + {APART.map((item) => ( +
    - - {renderFaqAnswer(item)} -
  • +

    {item.title}

    +

    + {item.body} +

    +
))} - -

-
-
- -
-
-

- 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)} -
-

- 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 +
+
+

+ Pricing

-

- 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} -
  • - ))} -
+
+
+

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. +

+
+
-
-

- Where Prisma ORM fits better +

+ +
+
+

+ When Prisma ORM is the wrong choice

-

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

-
    - {PRISMA_FITS.map((item) => ( -
  • - {item} +
      + {WRONG_CHOICE.map((item) => ( +
    • + {renderFaqAnswer(item)}
    • ))}
-

-
+ -
-
-

- What sets Prisma ORM apart -

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

{item.title}

-

- {item.body} -

-
- ))} -
-
-
- -
-
-

- Pricing -

-
-
-

Kysely

-

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

+
+
+
+

+ 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. +
-
-

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. -

+
+

+ 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. +
-
-
+
-
-
-

- When Prisma ORM is the wrong choice -

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

- Move from Kysely to Prisma ORM +
+
+

+ What teams on Prisma say

-
    -
  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. -
+

+ 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} +
+
+ ))} +
-
-

- Move from Prisma ORM to Kysely +

+ +
+
+

+ Frequently asked questions

-
    -
  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. -
+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
-

-
+ -
-
-

- What teams on Prisma say +
+

+ Ready to build with Prisma?

-

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

+ Free to start, no credit card required.

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-

-
- -
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
-
-
- - +
    +
  • Prisma ORM is free under the Apache License 2.0
  • +
  • Prisma Postgres Free includes 200,000 operations a month
  • +
  • Prisma ORM is optional with Prisma Postgres
  • +
+

+ + Get started free + + + Talk to us + +

+ + ); } diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 74d6e07bd9..a8a6ae81a5 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -2,6 +2,7 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { FAQ, KYSELY_PATH, KYSELY_TITLE } from "./_content"; +import { CompareDocument } from "../document"; import { KyselyPage } from "./_sections"; export const metadata = getComparisonMetadata("kysely"); @@ -14,7 +15,9 @@ export default function Page() { <> - + + + ); } diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index d1c9787ab5..32d97e433e 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -1,16 +1,13 @@ import Link from "next/link"; +import { Info } from "lucide-react"; 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 { ArticleActions } from "../document-chrome"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -36,463 +33,437 @@ const PROOF = TESTIMONIALS.filter( export function getMongoosePageContent() { return ( <> -
-
-
-
-
+
+
+

+ {MONGOOSE_TITLE} +

+ +
+

+ Pick{" "} + + Prisma ORM + {" "} + when a contract file should drive typed queries and migrations, on PostgreSQL or on + MongoDB. Pick{" "} + + 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. +

+ +

+ + Read the ORM docs + {" "} + + See which fits + +

+
+
+
+
+
+

+ Choose Prisma ORM if +

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

+ Choose Mongoose if +

+
    + {CHOOSE_MONGOOSE.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
- -
-
- Compare -

- {MONGOOSE_TITLE} -

-

- Pick{" "} - - Prisma ORM - {" "} - when a contract file should drive typed queries and migrations, on PostgreSQL or on - MongoDB. Pick{" "} +

+ +
+
+

+ 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. +

+
+
+ + + + + + + + + + {COMPARE_ROWS.map((row) => ( + + + + + + ))} + +
+ Feature + + Mongoose + + Prisma ORM +
+ {row.feature} + + {row.mongoose} + + {renderFaqAnswer(row.prisma)} +
+
+
+

+ 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{" "} - Mongoose + getting started guide {" "} - 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. + builds a schema, compiles a model, and queries with{" "} + find().{" "} + + populate() + {" "} + loads documents from another collection.

-

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

    + {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 + {" "} + describe that file as the contract. MongoDB support is early access, and the limits + are in the table above.

-
- - Read the ORM docs - - - See which fits - -
+
    + {PRISMA_FITS.map((item) => ( +
  • + {item} +
  • + ))} +
-
-
+ -
-
-
-

- Choose Prisma ORM if +
+
+

+ What sets Prisma ORM apart

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

- Choose Mongoose if -

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

    {item.title}

    +

    + {item.body} +

    +
))} - -

-
-
- -
-
-

- 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. -

-
-
- - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
- Feature - - Mongoose - - Prisma ORM -
- {row.feature} - - {row.mongoose} - - {renderFaqAnswer(row.prisma)} -
-

- 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 +
+
+

+ Pricing

-

- 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} -
  • - ))} -
+
+
+

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. +

+
+
-
-

- Where Prisma ORM fits better +

+ +
+
+

+ When Prisma ORM is the wrong choice

-

- Prisma ORM fits a team that wants the contract, the model queries, and the migrations - to come from one file. The{" "} - - ORM docs - {" "} - describe that file as the contract. MongoDB support is early access, and the limits - are in the table above. -

-
    - {PRISMA_FITS.map((item) => ( -
  • - {item} +
      + {WRONG_CHOICE.map((item) => ( +
    • + {renderFaqAnswer(item)}
    • ))}
-

-
- -
-
-

- What sets Prisma ORM apart -

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

{item.title}

-

- {item.body} -

-
- ))} -
-
-
+ -
-
-

- Pricing -

-
-
-

Mongoose

-

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

+
+
+
+

+ 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. +
-
-

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. -

+
+

+ 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. +
-
-
- -
-
-

- When Prisma ORM is the wrong choice -

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

- Move from Mongoose to Prisma ORM +
+
+

+ What teams on Prisma say

-
    -
  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. -
+

+ 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} +
+
+ ))} +
-
-

- Move from Prisma ORM to Mongoose +

+ +
+
+

+ Frequently asked questions

-
    -
  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. -
+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
-

-
+ -
-
-

- What teams on Prisma say +
+

+ Start with the Free plan

-

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

+ Free to start, no credit card required.

-
- {PROOF.map((item) => ( -
-
- "{item.quote}" -
-
- {item.name}, {item.role}, {item.company} -
-
- ))} -
-

-
- -
-
-

- Frequently asked questions -

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
-
-
- - +
    +
  • Prisma ORM is free under the Apache License 2.0
  • +
  • Prisma Postgres Free includes 200,000 operations a month
  • +
  • Prisma ORM is optional with Prisma Postgres
  • +
+

+ + Get started free + + + Talk to us + +

+ + ); } diff --git a/apps/site/src/app/compare/mongoose/page.tsx b/apps/site/src/app/compare/mongoose/page.tsx index a0f2ba92ff..011ca63642 100644 --- a/apps/site/src/app/compare/mongoose/page.tsx +++ b/apps/site/src/app/compare/mongoose/page.tsx @@ -2,6 +2,7 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { FAQ, MONGOOSE_PATH, MONGOOSE_TITLE } from "./_content"; +import { CompareDocument } from "../document"; import { MongoosePage } from "./_sections"; export const metadata = getComparisonMetadata("mongoose"); @@ -14,7 +15,9 @@ export default function Page() { <> - + + + ); } diff --git a/apps/site/src/app/compare/page.tsx b/apps/site/src/app/compare/page.tsx index 9d3ea78134..6c8848a8ac 100644 --- a/apps/site/src/app/compare/page.tsx +++ b/apps/site/src/app/compare/page.tsx @@ -1,15 +1,17 @@ import { COMPARISON_CTA } from "./_content"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { createCollectionPageStructuredData } from "@/lib/structured-data"; -import { COMPARISONS, COMPARISON_INDEX } from "./comparisons"; -import { RoleKicker } from "@/components/brand/role-kicker"; -import { Texture } from "@/components/brand/texture"; -import { CtaBurst } from "@/components/sections/cta-burst"; import { createPageMetadata } from "@/lib/page-metadata"; -import { CompareRows, CompareState } from "./compare-list"; +import { COMPARISONS, COMPARISON_CATEGORIES, COMPARISON_INDEX } from "./comparisons"; +import Link from "next/link"; +import { CompareDocument } from "./document"; +import { ArticleActions } from "./document-chrome"; export const metadata = createPageMetadata(COMPARISON_INDEX); +const INLINE = + "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-foreground/50"; + /** Render the comparison directory with its complete, unfiltered collection schema. */ export default function ComparePage() { return ( @@ -26,55 +28,73 @@ export default function ComparePage() { })), })} /> -
-
-
-
+
+
+

+ Prisma comparisons +

+ -
-
- - -
-
- - Compare - -

- Prisma comparisons -

-

- Prisma ORM and Prisma Postgres set beside the tools people weigh them against, with - every figure sourced and dated. -

-
+

+ {COMPARISON_INDEX.description} +

+
+
+
    + {COMPARISONS.map((entry) => { + const category = COMPARISON_CATEGORIES.find((item) => item.id === entry.category); + return ( +
  1. + + {category ? ( +

    + {category.label} +

    + ) : null} +

    + {entry.competitor} +

    +

    + {entry.title} +

    +

    + {entry.description} +

    + +
  2. + ); + })} +
+
+

+ Ready to build with Prisma? +

+

+ {COMPARISON_CTA.body} +

+
    + {COMPARISON_CTA.checks.map((item) => ( +
  • {item.label}
  • + ))} +
+

+ + {COMPARISON_CTA.primaryCta.label} + + + {COMPARISON_CTA.secondaryCta.label} + +

+
-
- - -
-
- -
-
-
- - + ); } diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index 41bfa3a711..78002d9f9b 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -117,7 +117,7 @@ export default function RootLayout({ {/* The campaign landing pages under /lp carry their own header and footer (app/lp/layout.tsx); SiteChrome drops the site's for them. */} - +
diff --git a/apps/site/src/components/site-chrome.tsx b/apps/site/src/components/site-chrome.tsx index fc0a2ced88..5025623fdd 100644 --- a/apps/site/src/components/site-chrome.tsx +++ b/apps/site/src/components/site-chrome.tsx @@ -5,14 +5,26 @@ import type { ReactNode } from "react"; import { isLandingPath } from "@/lib/landing-routes"; /** - * Wraps the parts of the root layout that the campaign landing pages under - * `/lp` replace with their own minimal chrome (see app/lp/layout.tsx). Every - * other route renders the children untouched. Reading the pathname here keeps - * the root layout a server component and avoids a second root layout, which - * would mean moving every existing route into a route group. + * Wraps the parts of the root layout that some routes replace. + * `/lp` drops the site header, banner, and footer. `/compare` drops the + * marketing header and banner because those pages use the docs-template + * document header, and keeps the footer. Reading the pathname here keeps the + * root layout a server component. */ -export function SiteChrome({ children }: { children: ReactNode }) { +/** Comparison pages carry the docs-template document header, so the marketing header stays off those routes. The footer stays. */ +function isCompareDocumentPath(pathname: string) { + return pathname === "/compare" || pathname.startsWith("/compare/"); +} + +export function SiteChrome({ + children, + hideOnCompare = false, +}: { + children: ReactNode; + hideOnCompare?: boolean; +}) { const pathname = usePathname(); if (isLandingPath(pathname)) return null; + if (hideOnCompare && isCompareDocumentPath(pathname)) return null; return <>{children}; } From c78f2af202a7c8014eee4e719d524b992cf8a010 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 14:05:02 +0000 Subject: [PATCH 02/52] site: lay comparison articles out like the guides Keep the /compare directory on the apps layout. The head-to-head pages use the guide article frame, with the opening and closing actions from the comparison pages. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 185 +++++ apps/site/src/app/compare/compare-list.tsx | 324 ++++++++ .../site/src/app/compare/convex/_sections.tsx | 755 ++++++++--------- apps/site/src/app/compare/convex/page.tsx | 5 +- apps/site/src/app/compare/document-chrome.tsx | 361 -------- apps/site/src/app/compare/document.tsx | 111 --- .../site/src/app/compare/kysely/_sections.tsx | 767 ++++++++--------- apps/site/src/app/compare/kysely/page.tsx | 5 +- .../src/app/compare/mongoose/_sections.tsx | 776 +++++++++--------- apps/site/src/app/compare/mongoose/page.tsx | 5 +- apps/site/src/app/compare/page.tsx | 122 ++- apps/site/src/app/layout.tsx | 2 +- apps/site/src/components/site-chrome.tsx | 24 +- 13 files changed, 1666 insertions(+), 1776 deletions(-) create mode 100644 apps/site/src/app/compare/article.tsx create mode 100644 apps/site/src/app/compare/compare-list.tsx delete mode 100644 apps/site/src/app/compare/document-chrome.tsx delete mode 100644 apps/site/src/app/compare/document.tsx diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx new file mode 100644 index 0000000000..5df02392e5 --- /dev/null +++ b/apps/site/src/app/compare/article.tsx @@ -0,0 +1,185 @@ +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 } from "./_content"; +import { COMPARISONS, type ComparisonSlug } from "./comparisons"; + +const PROSE = + "answer-prose prose max-w-none text-foreground 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 hover:prose-a:text-prism-cyan-700 prose-strong:font-semibold prose-li:leading-[1.8] prose-code:break-words prose-th:text-left"; + +const TOC: Record = { + kysely: [ + { id: "choose", label: "Choose Prisma ORM if" }, + { id: "choose-kysely", label: "Choose Kysely if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-kysely", label: "Where Kysely works well" }, + { id: "where-prisma", label: "Where Prisma ORM fits better" }, + { id: "apart", label: "What sets Prisma ORM apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, + { id: "migrate", label: "Move from Kysely to Prisma ORM" }, + { id: "migrate-back", label: "Move from Prisma ORM to Kysely" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Ready to build with Prisma?" }, + ], + mongoose: [ + { id: "choose", label: "Choose Prisma ORM if" }, + { id: "choose-mongoose", label: "Choose Mongoose if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-mongoose", label: "Where Mongoose works well" }, + { id: "where-prisma", label: "Where Prisma ORM fits better" }, + { id: "apart", label: "What sets Prisma ORM apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, + { id: "migrate", label: "Move from Mongoose to Prisma ORM" }, + { id: "migrate-back", label: "Move from Prisma ORM to Mongoose" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Ready to build with Prisma?" }, + ], + convex: [ + { id: "choose", label: "Choose Prisma Postgres if" }, + { id: "choose-convex", label: "Choose Convex if" }, + { id: "feature-table", label: "What each one supports" }, + { id: "where-convex", label: "Where Convex works well" }, + { id: "where-prisma", label: "Where Prisma Postgres fits better" }, + { id: "apart", label: "What sets Prisma Postgres apart" }, + { id: "pricing", label: "Pricing" }, + { id: "wrong-choice", label: "When Prisma Postgres is the wrong choice" }, + { id: "migrate", label: "Move from Convex to Prisma Postgres" }, + { id: "migrate-back", label: "Move from Prisma Postgres to Convex" }, + { id: "proof", label: "What teams on Prisma say" }, + { id: "faq", label: "Frequently asked questions" }, + { id: "next", label: "Ready to build with Prisma?" }, + ], +}; + +/** Buttons under the introduction, matching the comparison pages' opening actions. */ +export function comparisonHeroCtas() { + return ( +

+ + Get started free + {" "} + + See pricing + +

+ ); +} + +/** Closing action from the comparison pages: the shared checks, console, and contact. */ +export function comparisonClosingCta() { + return ( +
+

+ {COMPARISON_CTA.headline} +

+

{COMPARISON_CTA.body}

+
    + {COMPARISON_CTA.checks.map((item) => ( +
  • {item.label}
  • + ))} +
+

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

+
+ ); +} + +function articleParts(children: ReactNode) { + const parts = Children.toArray(children); + if (parts.length !== 3 || 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: header card, heading list, + * reading column, closing action, and related links. 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] = articleParts(children); + const related = COMPARISONS.filter((item) => item.slug !== slug); + + return ( +
+
+
+ + {intro} +
+
+ +
+ + +
+
{body}
+ {closing} + +
+
+
+ ); +} diff --git a/apps/site/src/app/compare/compare-list.tsx b/apps/site/src/app/compare/compare-list.tsx new file mode 100644 index 0000000000..9adf33180b --- /dev/null +++ b/apps/site/src/app/compare/compare-list.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; +import Link from "next/link"; +import { ArrowRightBold, ChevronsUpDown, Search, X } from "@/components/icons/forma"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + COMPARISON_CATEGORIES, + COMPARISONS, + type ComparisonCategory, + type ComparisonEntry, +} from "./comparisons"; + +type CategoryFilter = ComparisonCategory | "all"; +type SortKey = "name-asc" | "name-desc"; + +const SORT_OPTIONS: readonly { value: SortKey; label: string }[] = [ + { value: "name-asc", label: "Name A–Z" }, + { value: "name-desc", label: "Name Z–A" }, +]; + +type CompareContextValue = { + query: string; + setQuery: (value: string) => void; + filtered: readonly ComparisonEntry[]; +}; + +const CompareContext = createContext(null); + +function useCompare() { + const value = useContext(CompareContext); + if (!value) { + throw new Error("Compare controls must render inside CompareState"); + } + return value; +} + +export function CompareState({ children }: { children: ReactNode }) { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => COMPARISONS.filter((entry) => matches(entry, query)), [query]); + return ( + + {children} + + ); +} + +function CompareSearch() { + const { query, setQuery } = useCompare(); + + return ( +
+ + + setQuery(event.target.value)} + placeholder="Search comparisons" + autoComplete="off" + className="h-9 rounded-lg border-black/[0.09] bg-white pl-9 pr-9 text-sm shadow-none md:text-sm [&::-webkit-search-cancel-button]:appearance-none" + /> + {query ? ( + + ) : null} +
+ ); +} + +function CompareSort({ value, onChange }: { value: SortKey; onChange: (sort: SortKey) => void }) { + return ( + + ); +} + +function FilterGroup({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

+ {label} +

+
{children}
+
+ ); +} + +function FilterRow({ + active, + onClick, + count, + dot, + children, +}: { + active: boolean; + onClick: () => void; + count: number; + dot?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function CategoryPill({ + active, + onClick, + count, + dot, + children, +}: { + active: boolean; + onClick: () => void; + count: number; + dot?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function matches(entry: ComparisonEntry, query: string) { + const haystack = `${entry.competitor} ${entry.title} ${entry.description}`.toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((term) => haystack.includes(term)); +} + +export function CompareRows() { + const { query, filtered } = useCompare(); + const [category, setCategory] = useState("all"); + const [sort, setSort] = useState("name-asc"); + const counts = useMemo(() => { + const byCategory = Object.fromEntries( + COMPARISON_CATEGORIES.map((item) => [ + item.id, + COMPARISONS.filter((entry) => entry.category === item.id).length, + ]), + ) as Record; + return { all: COMPARISONS.length, ...byCategory }; + }, []); + const visibleCategories = COMPARISON_CATEGORIES.filter((item) => counts[item.id] > 0); + const visible = useMemo(() => { + const inCategory = + category === "all" ? filtered : filtered.filter((entry) => entry.category === category); + return [...inCategory].sort((a, b) => { + const order = a.competitor.localeCompare(b.competitor); + return sort === "name-desc" ? -order : order; + }); + }, [filtered, category, sort]); + const countLabel = visible.length === 1 ? "comparison" : "comparisons"; + const categoryHasEntries = category === "all" ? COMPARISONS.length > 0 : counts[category] > 0; + const showSearchEmpty = visible.length === 0 && query.trim() !== "" && categoryHasEntries; + + return ( +
+ + +
+
+ setCategory("all")} + count={counts.all} + > + All + + {visibleCategories.map((item) => ( + setCategory(item.id)} + count={counts[item.id]} + dot={item.dot} + > + {item.label} + + ))} +
+ +
+

+ {visible.length}{" "} + {countLabel} +

+
+
+ +
+ +
+
+ + {showSearchEmpty ? ( +

No comparisons match that search.

+ ) : null} + + {visible.length > 0 ? ( +
    + {visible.map((entry) => ( +
  1. + +

    + {entry.competitor} +

    + + + {entry.title} + + + {entry.description} + + + + +
  2. + ))} +
+ ) : null} +
+
+ ); +} diff --git a/apps/site/src/app/compare/convex/_sections.tsx b/apps/site/src/app/compare/convex/_sections.tsx index d89aa5f1b8..c0d82ab9f1 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -7,7 +7,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; -import { ArticleActions } from "../document-chrome"; +import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -31,434 +31,399 @@ const PROOF = TESTIMONIALS.filter( /** Build the shared content tree for the HTML page and its Markdown rendition. */ export function getConvexPageContent() { - 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 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. + 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 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. +

+
, +
+
+
+
+

+ Choose Prisma Postgres if +

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

+ Choose Convex if +

+
    + {CHOOSE_CONVEX.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
+
+
+ +
+
+

+ 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.

- -

- - Read the Postgres docs - {" "} - - See which fits - -

-
-
-
-
-
-

- Choose Prisma Postgres if -

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

- Choose Convex if -

-
    - {CHOOSE_CONVEX.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
+
+
+ + + + + + + + + + {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}. +

+
+ -
-
-

- What each one supports +
+
+
+

+ Where Convex works well

- 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 is a reactive database. Its{" "} - Convex overview - - ,{" "} - - frontend hosting - - , and{" "} - - pricing - - , {CHECKED}. Prisma cells:{" "} - - Prisma Postgres - - ,{" "} + overview + {" "} + describes TypeScript queries that rerun when their data changes, and a client library + that subscribes over a WebSocket. +

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

+ Where Prisma Postgres fits better +

+

+ Prisma Postgres fits a team that wants PostgreSQL.{" "} Prisma ORM - - ,{" "} + {" "} + is optional.{" "} Prisma Compute - - ,{" "} - - Compute limitations - - ,{" "} - - Composer building blocks - - , and the{" "} - - pricing page - - , {CHECKED}. + {" "} + is separate app hosting.

-
-
- -
-
-
-

- Where Convex works well -

-

- Convex is a reactive database. Its{" "} - - overview - {" "} - describes TypeScript queries that rerun when their data changes, and a client - library that subscribes over a WebSocket. -

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

- Where Prisma Postgres fits better -

-

- Prisma Postgres fits a team that wants PostgreSQL.{" "} - - Prisma ORM - {" "} - is optional.{" "} - - Prisma Compute - {" "} - is separate app hosting. -

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

- What sets Prisma Postgres apart -

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

{item.title}

-

- {item.body} -

-
+
    + {PRISMA_FITS.map((item) => ( +
  • + {item} +
  • ))} -
+
-
+

+
-
-
-

- Pricing -

-
-
-

Convex

-

- Free and Starter are $0, then pay as you go, and list 1 to 6 developers. The - resource table includes 0.5 GB of database storage. Professional is $25 per - developer per month. Business and Enterprise have a $2,500 monthly minimum ( - - Convex pricing - - , {CHECKED}). -

-
-
-

Prisma Postgres and Prisma Compute

+
+
+

+ What sets Prisma Postgres apart +

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

{item.title}

- 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. + {item.body}

-
+ ))}
-
- -
-
-

- When Prisma Postgres is 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. -
+
+
+

+ Pricing +

+
+
+

Convex

+

+ Free and Starter are $0, then pay as you go, and list 1 to 6 developers. The + resource table includes 0.5 GB of database storage. Professional is $25 per + developer per month. Business and Enterprise have a $2,500 monthly minimum ( + + Convex pricing + + , {CHECKED}). +

-
-

- 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. -
+
+

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. +

-
+
+
+ +
+
+

+ When Prisma Postgres is the wrong choice +

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

- What teams on Prisma say +
+
+
+

+ Move from Convex to Prisma Postgres

-

- 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} -
-
- ))} -
+
    +
  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. +
-
- -
-
-

- Frequently asked questions +
+

+ Move from Prisma Postgres to Convex

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
+
    +
  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. +
-

+

+
-
-

- Start with the Free plan +
+
+

+ What teams on Prisma say

- Free to start, no credit card required. -

-
    -
  • Prisma Postgres Free includes 200,000 operations a month
  • -
  • Prisma ORM is optional with Prisma Postgres
  • -
  • The pricing page does not list a per-developer fee
  • -
-

- - Get started free - - - Talk to us - + 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} +
+
+ ))} +
+ +

+ +
+
+

+ Frequently asked questions +

+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
+
+ , + comparisonClosingCta(), + ]; } -/** 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/page.tsx b/apps/site/src/app/compare/convex/page.tsx index 95d9f59d08..f74bf86ce0 100644 --- a/apps/site/src/app/compare/convex/page.tsx +++ b/apps/site/src/app/compare/convex/page.tsx @@ -2,7 +2,6 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { CONVEX_PATH, CONVEX_TITLE, FAQ } from "./_content"; -import { CompareDocument } from "../document"; import { ConvexPage } from "./_sections"; export const metadata = getComparisonMetadata("convex"); @@ -15,9 +14,7 @@ export default function Page() { <> - - - + ); } diff --git a/apps/site/src/app/compare/document-chrome.tsx b/apps/site/src/app/compare/document-chrome.tsx deleted file mode 100644 index dcdf58a477..0000000000 --- a/apps/site/src/app/compare/document-chrome.tsx +++ /dev/null @@ -1,361 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useSearchContext } from "fumadocs-ui/contexts/search"; -import { ArrowLeft, ArrowRight, ChevronLeft, Copy, Menu, Search } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; -import { COMPARISONS, type ComparisonSlug } from "./comparisons"; - -export type CompareSlug = ComparisonSlug | "index"; - -const NAV_LINK = - "flex rounded-lg px-2 py-1.5 font-medium transition-colors text-foreground/75 hover:bg-black/[0.04] hover:text-foreground"; - -const NAV_CURRENT = - "spectrum-border spectrum-border-on flex rounded-lg px-2 py-1.5 font-medium text-foreground"; - -export function CompareNav({ - current, - onNavigate, -}: { - current: CompareSlug; - onNavigate?: () => void; -}) { - return ( -
- - - All comparisons - -
    - {COMPARISONS.map((entry) => { - const active = entry.slug === current; - return ( -
  • - - {entry.competitor} - -
  • - ); - })} -
-
- ); -} - -function SearchButton({ compact = false }: { compact?: boolean }) { - const { setOpenSearch } = useSearchContext(); - - if (compact) { - return ( - - ); - } - - return ( - - ); -} - -export function CompareHeader({ current }: { current: CompareSlug }) { - const [open, setOpen] = useState(false); - - return ( -
-
-
- - {/* The document header uses the same mark as the docs template. */} - Prisma - - - / - - - compare - -
-
- - - - - - - - - - Comparisons - - - - -
-
-
- ); -} - -export function CompareToc({ items }: { items: readonly { id: string; label: string }[] }) { - const [active, setActive] = useState(items[0]?.id ?? ""); - - useEffect(() => { - const nodes = items - .map((item) => document.getElementById(item.id)) - .filter((node): node is HTMLElement => node !== null); - if (nodes.length === 0) return; - - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((entry) => entry.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - const next = visible[0]?.target.id; - if (next) setActive(next); - }, - { rootMargin: "-96px 0px -65% 0px", threshold: [0, 1] }, - ); - for (const node of nodes) observer.observe(node); - return () => observer.disconnect(); - }, [items]); - - return ( - - ); -} - -export function ArticleActions({ - markdownPath, - sourcePath, -}: { - markdownPath: string; - sourcePath: string; -}) { - const [copied, setCopied] = useState(false); - const [origin, setOrigin] = useState("https://www.prisma.io"); - - useEffect(() => { - setOrigin(window.location.origin); - }, []); - - const markdownUrl = `${origin}${markdownPath}.md`; - const prompt = `Read ${markdownUrl}, I want to ask questions about it.`; - const tools = [ - { - label: "Open in ChatGPT", - href: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`, - icon: , - }, - { - label: "Open in Claude", - href: `https://claude.ai/new?q=${encodeURIComponent(prompt)}`, - icon: , - }, - { - label: "Open in GitHub", - href: `https://github.com/prisma/web/blob/main/${sourcePath}`, - icon: , - }, - ]; - - return ( -
-
- Open in -
- {tools.map((tool) => ( - - ))} -
-
- -
- ); -} - -const PAGER = - "group flex flex-col rounded-xl border border-black/[0.06] bg-gradient-to-br from-prism-cyan-50/50 via-card to-card p-5 shadow-[0_1px_2px_rgba(21,21,21,0.04)] transition-[box-shadow,border-color] hover:border-black/10 hover:shadow-[0_1px_2px_rgba(21,21,21,0.04),0_14px_28px_-20px_rgba(21,21,21,0.25)]"; - -export function ArticlePager({ - previous, - next, -}: { - previous?: { href: string; label: string }; - next?: { href: string; label: string }; -}) { - if (!previous && !next) return null; - return ( -
- -
- ); -} - -function GithubMark() { - return ( - - - - ); -} - -function DiscordMark() { - return ( - - - - ); -} - -function ChatGptMark() { - return ( - - - - ); -} - -function ClaudeMark() { - return ( - - - - ); -} diff --git a/apps/site/src/app/compare/document.tsx b/apps/site/src/app/compare/document.tsx deleted file mode 100644 index 7970238baa..0000000000 --- a/apps/site/src/app/compare/document.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type { ReactNode } from "react"; -import { Texture } from "@/components/brand/texture"; -import { cn } from "@/lib/utils"; -import { COMPARISONS, type ComparisonSlug } from "./comparisons"; -import { - ArticlePager, - CompareHeader, - CompareNav, - CompareToc, - type CompareSlug, -} from "./document-chrome"; - -const TOC: Record = { - kysely: [ - { id: "choose", label: "Choose Prisma ORM if" }, - { id: "choose-kysely", label: "Choose Kysely if" }, - { id: "feature-table", label: "What each one supports" }, - { id: "where-kysely", label: "Where Kysely works well" }, - { id: "where-prisma", label: "Where Prisma ORM fits better" }, - { id: "apart", label: "What sets Prisma ORM apart" }, - { id: "pricing", label: "Pricing" }, - { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, - { id: "migrate", label: "Move from Kysely to Prisma ORM" }, - { id: "migrate-back", label: "Move from Prisma ORM to Kysely" }, - { id: "proof", label: "What teams on Prisma say" }, - { id: "faq", label: "Frequently asked questions" }, - { id: "next", label: "Ready to build with Prisma?" }, - ], - mongoose: [ - { id: "choose", label: "Choose Prisma ORM if" }, - { id: "choose-mongoose", label: "Choose Mongoose if" }, - { id: "feature-table", label: "What each one supports" }, - { id: "where-mongoose", label: "Where Mongoose works well" }, - { id: "where-prisma", label: "Where Prisma ORM fits better" }, - { id: "apart", label: "What sets Prisma ORM apart" }, - { id: "pricing", label: "Pricing" }, - { id: "wrong-choice", label: "When Prisma ORM is the wrong choice" }, - { id: "migrate", label: "Move from Mongoose to Prisma ORM" }, - { id: "migrate-back", label: "Move from Prisma ORM to Mongoose" }, - { id: "proof", label: "What teams on Prisma say" }, - { id: "faq", label: "Frequently asked questions" }, - { id: "next", label: "Start with the Free plan" }, - ], - convex: [ - { id: "choose", label: "Choose Prisma Postgres if" }, - { id: "choose-convex", label: "Choose Convex if" }, - { id: "feature-table", label: "What each one supports" }, - { id: "where-convex", label: "Where Convex works well" }, - { id: "where-prisma", label: "Where Prisma Postgres fits better" }, - { id: "apart", label: "What sets Prisma Postgres apart" }, - { id: "pricing", label: "Pricing" }, - { id: "wrong-choice", label: "When Prisma Postgres is the wrong choice" }, - { id: "migrate", label: "Move from Convex to Prisma Postgres" }, - { id: "migrate-back", label: "Move from Prisma Postgres to Convex" }, - { id: "proof", label: "What teams on Prisma say" }, - { id: "faq", label: "Frequently asked questions" }, - { id: "next", label: "Start with the Free plan" }, - ], -}; - -const ORDER = ["index", ...COMPARISONS.map((entry) => entry.slug)] as const; - -function neighbor(slug: CompareSlug, direction: -1 | 1) { - const index = ORDER.indexOf(slug); - const next = ORDER[index + direction]; - if (!next) return undefined; - if (next === "index") return { href: "/compare", label: "All comparisons" }; - const entry = COMPARISONS.find((item) => item.slug === next); - return entry ? { href: `/compare/${entry.slug}`, label: entry.competitor } : undefined; -} - -/** - * Document page for /compare, matching the docs template: textured ground, - * section header, sidebar, reading card, and a table of contents on articles. - */ -export function CompareDocument({ slug, children }: { slug: CompareSlug; children: ReactNode }) { - const toc = slug === "index" ? undefined : TOC[slug]; - - return ( -
-
- -
- -
-
- -
-
- {children} - -
-
- {toc ? : null} -
-
-
- ); -} diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 1e79a2ad75..eb39535bd0 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -7,7 +7,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; -import { ArticleActions } from "../document-chrome"; +import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -31,439 +31,402 @@ const PROOF = TESTIMONIALS.filter( /** Build the shared content tree for the HTML page and its Markdown rendition. */ export function getKyselyPageContent() { - return ( - <> -
-
-

- {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{" "} - - 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. + return [ +

+

+ {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{" "} + + 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. +

+
, +
+
+
+
+

+ Choose Prisma ORM if +

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

+ Choose Kysely if +

+
    + {CHOOSE_KYSELY.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
+
+
+ +
+
+

+ 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.

- -

- - Read the ORM docs - {" "} - - See which fits - -

-
-
-
-
-
-

- Choose Prisma ORM if -

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

- Choose Kysely if -

-
    - {CHOOSE_KYSELY.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
+
+
+ + + + + + + + + + {COMPARE_ROWS.map((row) => ( + + + + + + ))} + +
+ Feature + + Kysely + + Prisma ORM +
+ {row.feature} + + {row.kysely} + + {renderFaqAnswer(row.prisma)} +
-
+

+ 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 + + . +

+
+ -
-
-

- What each one supports +
+
+
+

+ Where Kysely works well

- 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)} -
-
-
-

- Kysely cells:{" "} + Kysely is a SQL query builder. Its{" "} - 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 - - . + 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 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 - {" "} - describe that file as the contract. -

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

- What sets Prisma ORM apart +
+

+ Where Prisma ORM fits better

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

{item.title}

-

- {item.body} -

-
+

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

+
    + {PRISMA_FITS.map((item) => ( +
  • + {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

+
+
+

+ What sets Prisma ORM apart +

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

{item.title}

- 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. + {item.body}

-
+ ))}
-
- -
-
-

- When Prisma ORM is the wrong choice -

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

- 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. -
+
+
+

+ Pricing +

+
+
+

Kysely

+

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

-
-

- 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. -
+
+

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. +

-
+
+
+ +
+
+

+ When Prisma ORM is the wrong choice +

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

- What teams on Prisma say +
+
+
+

+ Move from Kysely to Prisma ORM

-

- 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} -
-
- ))} -
+
    +
  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. +
-
- -
-
-

- Frequently asked questions +
+

+ Move from Prisma ORM to Kysely

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
+
    +
  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. +
-

+

+
-
-

- Ready to build with Prisma? +
+
+

+ What teams on Prisma say

- Free to start, no credit card required. -

-
    -
  • Prisma ORM is free under the Apache License 2.0
  • -
  • Prisma Postgres Free includes 200,000 operations a month
  • -
  • Prisma ORM is optional with Prisma Postgres
  • -
-

- - Get started free - - - Talk to us - + 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} +
+
+ ))} +
+ +

+ +
+
+

+ Frequently asked questions +

+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
+
+ , + comparisonClosingCta(), + ]; } -/** 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/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index a8a6ae81a5..74d6e07bd9 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -2,7 +2,6 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { FAQ, KYSELY_PATH, KYSELY_TITLE } from "./_content"; -import { CompareDocument } from "../document"; import { KyselyPage } from "./_sections"; export const metadata = getComparisonMetadata("kysely"); @@ -15,9 +14,7 @@ export default function Page() { <> - - - + ); } diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index 32d97e433e..0fd204c148 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -7,7 +7,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; -import { ArticleActions } from "../document-chrome"; +import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -31,444 +31,410 @@ const PROOF = TESTIMONIALS.filter( /** Build the shared content tree for the HTML page and its Markdown rendition. */ export function getMongoosePageContent() { - return ( - <> -
-
-

- {MONGOOSE_TITLE} -

- -
-

- Pick{" "} - - Prisma ORM - {" "} - when a contract file should drive typed queries and migrations, on PostgreSQL or on - MongoDB. Pick{" "} - - 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. + return [ +

+

+ {MONGOOSE_TITLE} +

+

+ Pick{" "} + + Prisma ORM + {" "} + when a contract file should drive typed queries and migrations, on PostgreSQL or on MongoDB. + Pick{" "} + + 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. +

+
, +
+
+
+
+

+ Choose Prisma ORM if +

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

+ Choose Mongoose if +

+
    + {CHOOSE_MONGOOSE.map((item) => ( +
  • + {renderFaqAnswer(item)} +
  • + ))} +
+
+
+
+ +
+
+

+ 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.

- -

- - Read the ORM docs - {" "} - - See which fits - -

-
-
-
-
-
-

- Choose Prisma ORM if -

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

- Choose Mongoose if -

-
    - {CHOOSE_MONGOOSE.map((item) => ( -
  • - {renderFaqAnswer(item)} -
  • - ))} -
+
+
+ + + + + + + + + + {COMPARE_ROWS.map((row) => ( + + + + + + ))} + +
+ Feature + + Mongoose + + Prisma ORM +
+ {row.feature} + + {row.mongoose} + + {renderFaqAnswer(row.prisma)} +
-
+

+ 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 + + . +

+
+ -
-
-

- What each one supports +
+
+
+

+ Where Mongoose works well

- 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. -

-
-
- - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
- Feature - - Mongoose - - Prisma ORM -
- {row.feature} - - {row.mongoose} - - {renderFaqAnswer(row.prisma)} -
-
-
-

- Mongoose cells:{" "} + Mongoose is a MongoDB object modeling library. Its{" "} - Getting started + getting started guide {" "} - (v9.11.2),{" "} + builds a schema, compiles a model, and queries with{" "} + find().{" "} - 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 - - . + populate() + {" "} + loads documents from another collection.

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

- 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 - {" "} - describe that file as the contract. MongoDB support is early access, and the limits - are in the table above. -

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

- What sets Prisma ORM apart +
+

+ Where Prisma ORM fits better

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

{item.title}

-

- {item.body} -

-
+

+ Prisma ORM fits a team that wants the contract, the model queries, and the migrations + to come from one file. The{" "} + + ORM docs + {" "} + describe that file as the contract. MongoDB support is early access, and the limits + are in the table above. +

+
    + {PRISMA_FITS.map((item) => ( +
  • + {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

+
+
+

+ What sets Prisma ORM apart +

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

{item.title}

- 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. + {item.body}

-
+ ))}
-
- -
-
-

- When Prisma ORM is the wrong choice -

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

- 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. -
+
+
+

+ Pricing +

+
+
+

Mongoose

+

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

-
-

- 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. -
+
+

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. +

-
+
+
+ +
+
+

+ When Prisma ORM is the wrong choice +

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

- What teams on Prisma say +
+
+
+

+ Move from Mongoose to Prisma ORM

-

- 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} -
-
- ))} -
+
    +
  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. +
-
- -
-
-

- Frequently asked questions +
+

+ Move from Prisma ORM to Mongoose

-
- - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - -
+
    +
  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. +
-

+

+
-
-

- Start with the Free plan +
+
+

+ What teams on Prisma say

- Free to start, no credit card required. -

-
    -
  • Prisma ORM is free under the Apache License 2.0
  • -
  • Prisma Postgres Free includes 200,000 operations a month
  • -
  • Prisma ORM is optional with Prisma Postgres
  • -
-

- - Get started free - - - Talk to us - + 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} +
+
+ ))} +
+ +

+ +
+
+

+ Frequently asked questions +

+
+ + {FAQ.map((faq, index) => ( + + + {faq.question} + + + {renderFaqAnswer(faq.answer)} + + + ))} + +
+
+
+ , + comparisonClosingCta(), + ]; } -/** 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/page.tsx b/apps/site/src/app/compare/mongoose/page.tsx index 011ca63642..a0f2ba92ff 100644 --- a/apps/site/src/app/compare/mongoose/page.tsx +++ b/apps/site/src/app/compare/mongoose/page.tsx @@ -2,7 +2,6 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { FAQ, MONGOOSE_PATH, MONGOOSE_TITLE } from "./_content"; -import { CompareDocument } from "../document"; import { MongoosePage } from "./_sections"; export const metadata = getComparisonMetadata("mongoose"); @@ -15,9 +14,7 @@ export default function Page() { <> - - - + ); } diff --git a/apps/site/src/app/compare/page.tsx b/apps/site/src/app/compare/page.tsx index 6c8848a8ac..9d3ea78134 100644 --- a/apps/site/src/app/compare/page.tsx +++ b/apps/site/src/app/compare/page.tsx @@ -1,17 +1,15 @@ import { COMPARISON_CTA } from "./_content"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { createCollectionPageStructuredData } from "@/lib/structured-data"; +import { COMPARISONS, COMPARISON_INDEX } from "./comparisons"; +import { RoleKicker } from "@/components/brand/role-kicker"; +import { Texture } from "@/components/brand/texture"; +import { CtaBurst } from "@/components/sections/cta-burst"; import { createPageMetadata } from "@/lib/page-metadata"; -import { COMPARISONS, COMPARISON_CATEGORIES, COMPARISON_INDEX } from "./comparisons"; -import Link from "next/link"; -import { CompareDocument } from "./document"; -import { ArticleActions } from "./document-chrome"; +import { CompareRows, CompareState } from "./compare-list"; export const metadata = createPageMetadata(COMPARISON_INDEX); -const INLINE = - "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-foreground/50"; - /** Render the comparison directory with its complete, unfiltered collection schema. */ export default function ComparePage() { return ( @@ -28,73 +26,55 @@ export default function ComparePage() { })), })} /> - -
-
-

- Prisma comparisons -

- +
+
+
+
+
+ + +
+
+ + Compare + +

+ Prisma comparisons +

+

+ Prisma ORM and Prisma Postgres set beside the tools people weigh them against, with + every figure sourced and dated. +

+
-

- {COMPARISON_INDEX.description} -

-
-
-
    - {COMPARISONS.map((entry) => { - const category = COMPARISON_CATEGORIES.find((item) => item.id === entry.category); - return ( -
  1. - - {category ? ( -

    - {category.label} -

    - ) : null} -

    - {entry.competitor} -

    -

    - {entry.title} -

    -

    - {entry.description} -

    - -
  2. - ); - })} -
-
-

- Ready to build with Prisma? -

-

- {COMPARISON_CTA.body} -

-
    - {COMPARISON_CTA.checks.map((item) => ( -
  • {item.label}
  • - ))} -
-

- - {COMPARISON_CTA.primaryCta.label} - - - {COMPARISON_CTA.secondaryCta.label} - -

-
-
+ + + +
+
+ +
+
+
+ + ); } diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index 78002d9f9b..41bfa3a711 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -117,7 +117,7 @@ export default function RootLayout({ {/* The campaign landing pages under /lp carry their own header and footer (app/lp/layout.tsx); SiteChrome drops the site's for them. */} - +
diff --git a/apps/site/src/components/site-chrome.tsx b/apps/site/src/components/site-chrome.tsx index 5025623fdd..fc0a2ced88 100644 --- a/apps/site/src/components/site-chrome.tsx +++ b/apps/site/src/components/site-chrome.tsx @@ -5,26 +5,14 @@ import type { ReactNode } from "react"; import { isLandingPath } from "@/lib/landing-routes"; /** - * Wraps the parts of the root layout that some routes replace. - * `/lp` drops the site header, banner, and footer. `/compare` drops the - * marketing header and banner because those pages use the docs-template - * document header, and keeps the footer. Reading the pathname here keeps the - * root layout a server component. + * Wraps the parts of the root layout that the campaign landing pages under + * `/lp` replace with their own minimal chrome (see app/lp/layout.tsx). Every + * other route renders the children untouched. Reading the pathname here keeps + * the root layout a server component and avoids a second root layout, which + * would mean moving every existing route into a route group. */ -/** Comparison pages carry the docs-template document header, so the marketing header stays off those routes. The footer stays. */ -function isCompareDocumentPath(pathname: string) { - return pathname === "/compare" || pathname.startsWith("/compare/"); -} - -export function SiteChrome({ - children, - hideOnCompare = false, -}: { - children: ReactNode; - hideOnCompare?: boolean; -}) { +export function SiteChrome({ children }: { children: ReactNode }) { const pathname = usePathname(); if (isLandingPath(pathname)) return null; - if (hideOnCompare && isCompareDocumentPath(pathname)) return null; return <>{children}; } From ba6e3fbe1ebce3922380f75b8269736b7d6bcdd3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 20:30:34 +0000 Subject: [PATCH 03/52] site: refine the Kysely comparison page Put the source note at the bottom, merge the choose lists into one table, even out the section rhythm, and show the customer quotes in a carousel. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 8 +- .../site/src/app/compare/kysely/_sections.tsx | 646 +++++++++--------- .../src/app/compare/kysely/quote-carousel.tsx | 54 ++ 3 files changed, 378 insertions(+), 330 deletions(-) create mode 100644 apps/site/src/app/compare/kysely/quote-carousel.tsx diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 5df02392e5..e393f26d4e 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -10,8 +10,7 @@ const PROSE = const TOC: Record = { kysely: [ - { id: "choose", label: "Choose Prisma ORM if" }, - { id: "choose-kysely", label: "Choose Kysely if" }, + { id: "choose", label: "When should I choose Prisma ORM or Kysely" }, { id: "feature-table", label: "What each one supports" }, { id: "where-kysely", label: "Where Kysely works well" }, { id: "where-prisma", label: "Where Prisma ORM fits better" }, @@ -101,7 +100,7 @@ export function comparisonClosingCta() { function articleParts(children: ReactNode) { const parts = Children.toArray(children); - if (parts.length !== 3 || parts.some((part) => !isValidElement(part))) { + 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; @@ -114,7 +113,7 @@ function articleParts(children: ReactNode) { 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] = articleParts(children); + const [intro, body, closing, footnote] = articleParts(children); const related = COMPARISONS.filter((item) => item.slug !== slug); return ( @@ -178,6 +177,7 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child ))} + {footnote} diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index eb39535bd0..231e9cca32 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { Info } from "lucide-react"; import { Accordion, AccordionContent, @@ -8,6 +7,7 @@ import { } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { QuoteCarousel } from "./quote-carousel"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -25,6 +25,9 @@ import { const INLINE = "font-medium text-foreground underline underline-offset-4 decoration-black/20 transition-colors hover:decoration-black/60"; +/** One gap under every heading, and the same gap between the blocks inside a section. */ +const SECTION = "flex flex-col gap-4"; + const PROOF = TESTIMONIALS.filter( (item) => item.name === "Ron Cohen" || item.name === "Omar López", ); @@ -58,14 +61,7 @@ export function getKyselyPageContent() { {" "} are listed in one place.

- -

+

Read the ORM docs {" "} @@ -75,354 +71,352 @@ export function getKyselyPageContent() {

{comparisonHeroCtas()} , -
-
-
-
-

- Choose Prisma ORM if -

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

    + When should I choose Prisma ORM or Kysely +

    +
    + + + + + + + + + {CHOOSE_PRISMA.map((prisma, index) => ( + + + + ))} - - -
    -

    - Choose Kysely if -

    -
      - {CHOOSE_KYSELY.map((item) => ( -
    • - {renderFaqAnswer(item)} -
    • +
    +
    + Prisma ORM + + Kysely +
    + {renderFaqAnswer(prisma)} + + {renderFaqAnswer(CHOOSE_KYSELY[index])} +
    +
    +
    + +
    +

    + 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)} +
    +

    + 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 + {" "} + describe that file as the contract. +

    +
      + {PRISMA_FITS.map((item) => ( +
    • + {item} +
    • + ))} +
    -
    -
    -

    - 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)} -
    +
    +

    + What sets Prisma ORM apart +

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

    {item.title}

    +

    + {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 -

    +
    +

    + Pricing +

    +
    +
    +

    Kysely

    - 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. + The library is free under the{" "} + + MIT license + + . There is no Kysely hosting plan. You bring the database. Checked {CHECKED}.

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

    - Where Prisma ORM fits better -

    +
    +

    Prisma ORM and Prisma Postgres

    - Prisma ORM fits a team that wants the contract, the model queries, and the migrations - to come from one file. The{" "} - - ORM docs - {" "} - describe that file as the contract. + 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.

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

    - What sets Prisma ORM apart -

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

    {item.title}

    -

    - {item.body} -

    -
    - ))} -
    -
    +
    +

    + When Prisma ORM is 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. -

    -
    -
    -
    +
    +

    + 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. +
    -
    -
    -

    - When Prisma ORM is the wrong choice -

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

    + 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. +
    -
    -
    -
    -

    - 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. -
    -
    -
    +
    +

    + 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.company === "Bucket" ? ( + + + + ) : ( + + )} +
    +
    + "{item.quote}" +
    +
    + {item.name}, {item.role}, {item.company} +
    +
    + ))} +
    -
    -
    -

    - 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} -
    -
    +
    +

    + 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(), +

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

    , ]; } diff --git a/apps/site/src/app/compare/kysely/quote-carousel.tsx b/apps/site/src/app/compare/kysely/quote-carousel.tsx new file mode 100644 index 0000000000..89817488bd --- /dev/null +++ b/apps/site/src/app/compare/kysely/quote-carousel.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Children, useState, type ReactNode } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +/** One quote at a time. The figures stay in the document so the text is still readable and copyable. */ +export function QuoteCarousel({ children }: { children: ReactNode }) { + const slides = Children.toArray(children); + const count = slides.length; + const [index, setIndex] = useState(0); + const go = (direction: -1 | 1) => setIndex((current) => (current + direction + count) % count); + + return ( +
    +
    +
    + {slides.map((slide, slideIndex) => ( +
    + {slide} +
    + ))} +
    +
    +
    + + +

    + {index + 1} of {count} +

    +
    +
    + ); +} From cd4c2dc7c7f4b44367f64881fc928a1b60e689f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 21:21:35 +0000 Subject: [PATCH 04/52] site: design pass on the Kysely comparison Rewrite the lede without cross-links or a status checklist, and move the disclosure to a quiet line under it. Give the body one rhythm: prose margins zeroed, 20px under every heading, 56px between sections, one body size and colour, one code span treatment. Make the choose table two equal columns, keep the feature table's labels in view on phones, and replace the bibliography sentence with a Sources list at the end. Close every FAQ item on load, frame the quote carousel, and add a clean Bucket wordmark. Render the CTA buttons in divs so they no longer nest a div inside a p. Co-authored-by: rensburg --- apps/site/public/logos/customers/bucket.png | Bin 0 -> 4764 bytes apps/site/src/app/compare/kysely/_content.ts | 36 +- .../site/src/app/compare/kysely/_sections.tsx | 469 +++++++++--------- .../src/app/compare/kysely/quote-carousel.tsx | 50 +- 4 files changed, 286 insertions(+), 269 deletions(-) create mode 100644 apps/site/public/logos/customers/bucket.png diff --git a/apps/site/public/logos/customers/bucket.png b/apps/site/public/logos/customers/bucket.png new file mode 100644 index 0000000000000000000000000000000000000000..7da299bc92d79262f6dde4074ca22b3edd870fe1 GIT binary patch literal 4764 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 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: "Migrations", href: "https://kysely.dev/docs/migrations" }, + { 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: "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: "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 [ -
    -

    +
    +

    {KYSELY_TITLE}

    -

    - Pick{" "} +

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

    -

    - - Read the ORM docs - {" "} - - See which fits - +

    + Prisma makes Prisma ORM; Kysely is an independent open-source project. Figures about Kysely + come from its own pages, listed under{" "} + + Sources + {" "} + and checked {CHECKED}.

    - {comparisonHeroCtas()} +
    + + Get started free + {" "} + + See pricing + +
    , -
    +
    -

    - When should I choose Prisma ORM or Kysely -

    -
    - +

    When should I choose Prisma ORM or Kysely

    +
    +
    - - + - @@ -94,12 +153,8 @@ export function getKyselyPageContent() { {CHOOSE_PRISMA.map((prisma, index) => ( - - + + ))} @@ -107,25 +162,23 @@ export function getKyselyPageContent() { -
    -

    - 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. +

    +

    What each one supports

    +

    + 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.

    -
    -
    +
    Prisma ORM + Kysely
    - {renderFaqAnswer(prisma)} - - {renderFaqAnswer(CHOOSE_KYSELY[index])} - {renderFaqAnswer(prisma)}{renderFaqAnswer(CHOOSE_KYSELY[index])}
    +
    +
    - - + - - @@ -133,92 +186,38 @@ export function getKyselyPageContent() { {COMPARE_ROWS.map((row) => ( - - - + + ))}
    +
    Feature + Kysely + Prisma ORM
    + {row.feature} - {row.kysely} - - {renderFaqAnswer(row.prisma)} - {renderFaqAnswer(row.kysely)}{renderFaqAnswer(row.prisma)}
    -

    - 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 -

    -

    +

    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 + shows selectFrom and where, and lists joins among the other SQL features. Each built-in dialect needs its driver.

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

    - Where Prisma ORM fits better -

    -

    +

    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{" "} @@ -226,119 +225,100 @@ export function getKyselyPageContent() { {" "} describe that file as the contract.

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

    - What sets Prisma ORM apart -

    -
    +

    What sets Prisma ORM apart

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

    {item.title}

    -

    - {item.body} -

    +

    {renderFaqAnswer(item.body)}

    ))}
    -
    -

    - Pricing -

    -
    -
    +
    +

    Pricing

    +
    +

    Kysely

    -

    +

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

    -
    +

    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 ( + . Prisma Postgres is optional, and it works with any Postgres client, so Prisma ORM is + optional with it. Its{" "} - pricing page - - , {CHECKED}). Prisma Compute is separate app hosting. + Free plan + {" "} + is $0, with 200,000 operations a month, 2 GB of storage, and 50 databases. Prisma + Compute is separate app hosting.

    -

    - When Prisma ORM is the wrong choice -

    -
      +

      When Prisma ORM is the wrong choice

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

    - Move from Kysely to Prisma ORM -

    -
      +
      +

      Move from Kysely to Prisma ORM

      +
      1. - For PostgreSQL, run npx prisma@latest orm init{" "} - in the app. The{" "} + For PostgreSQL, run npx prisma@latest orm init in the app. The{" "} existing-database guide {" "} lists the Node.js versions it expects.
      2. - 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. + 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.
      3. Later changes go through{" "} contract emit and migration plan - . On PostgreSQL, migration show prints the SQL. + . On PostgreSQL, migration show prints the SQL.
      -

      - Move from Prisma ORM to Kysely -

      -
        +

        Move from Prisma ORM to Kysely

        +
        1. Keep the database and the connection string.
        2. - 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{" "} + 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 {" "} @@ -346,9 +326,8 @@ export function getKyselyPageContent() {
        3. 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{" "} + dialect's driver. On PostgreSQL that is PostgresDialect and{" "} + pg. On Prisma Postgres, follow the{" "} Kysely quickstart @@ -357,35 +336,25 @@ export function getKyselyPageContent() {
      -
      -

      - What teams on Prisma say -

      -

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

      +
      +

      What teams on Prisma say

      +

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

      {PROOF.map((item) => ( -
      -
      - {item.company === "Bucket" ? ( - - - - ) : ( - - )} -
      -
      +
      + +
      "{item.quote}"
      -
      - {item.name}, {item.role}, {item.company} +
      + {item.name}, {item.role},{" "} + {item.company}
      ))} @@ -393,30 +362,72 @@ export function getKyselyPageContent() {
      -

      - 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(), -

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

    , +
    +

    + {COMPARISON_CTA.headline} +

    +

    {COMPARISON_CTA.body}

    +
      + {COMPARISON_CTA.checks.map((item) => ( +
    • {item.label}
    • + ))} +
    +
    + + {COMPARISON_CTA.primaryCta.label} + {" "} + + {COMPARISON_CTA.secondaryCta.label} + +
    +
    , +
    +

    + Sources +

    +

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

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

    {group.product}

    +
      + {group.links.map((link) => ( +
    • + {link.label} +
    • + ))} +
    +
    + ))} +
    +
    , ]; } diff --git a/apps/site/src/app/compare/kysely/quote-carousel.tsx b/apps/site/src/app/compare/kysely/quote-carousel.tsx index 89817488bd..fa7033f656 100644 --- a/apps/site/src/app/compare/kysely/quote-carousel.tsx +++ b/apps/site/src/app/compare/kysely/quote-carousel.tsx @@ -3,6 +3,9 @@ import { Children, useState, type ReactNode } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; +const CONTROL = + "inline-flex size-9 items-center justify-center rounded-full border border-black/[0.08] text-foreground outline-none transition-colors hover:bg-black/[0.04] focus-visible:ring-[3px] focus-visible:ring-ring/50"; + /** One quote at a time. The figures stay in the document so the text is still readable and copyable. */ export function QuoteCarousel({ children }: { children: ReactNode }) { const slides = Children.toArray(children); @@ -11,8 +14,12 @@ export function QuoteCarousel({ children }: { children: ReactNode }) { const go = (direction: -1 | 1) => setIndex((current) => (current + direction + count) % count); return ( -
    -
    +
    +
    {slide} @@ -28,26 +37,23 @@ export function QuoteCarousel({ children }: { children: ReactNode }) { ))}
    -
    - - -

    - {index + 1} of {count} -

    +
    + + {index + 1} / {count} + +
    + + +
    ); From 49fb7440e0103f07e6cefdf6b17a7744595db27b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 21:27:30 +0000 Subject: [PATCH 05/52] site: align the Kysely tables and keep feature labels in view Put Kysely first in the choose table so both tables share the alphabetical column order. On narrow screens the feature table keeps its label column in place, shows a divider and a right-edge shadow while more columns are off-screen. Co-authored-by: rensburg --- .../site/src/app/compare/kysely/_sections.tsx | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 458a97316d..fc0932d7fe 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -50,8 +50,18 @@ const TABLE_FRAME = "overflow-x-auto rounded-xl border border-black/[0.08] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"; const TH_COL = "bg-neutral-50 text-sm font-semibold text-primary"; const TH_ROW = "bg-background text-sm font-semibold text-primary"; +/** + * 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. + */ +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 feature row's label in view while the table scrolls sideways on phones. */ -const STICKY = "sticky left-0 z-10 shadow-[1px_0_0_rgb(0_0_0/0.06)]"; +const STICKY = + "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-black/[0.06]"; const TD = "text-sm leading-6 text-foreground"; const PROOF = TESTIMONIALS.filter( @@ -143,18 +153,18 @@ export function getKyselyPageContent() { - Prisma ORM + Kysely - Kysely + Prisma ORM - {CHOOSE_PRISMA.map((prisma, index) => ( - - {renderFaqAnswer(prisma)} - {renderFaqAnswer(CHOOSE_KYSELY[index])} + {CHOOSE_KYSELY.map((kysely, index) => ( + + {renderFaqAnswer(kysely)} + {renderFaqAnswer(CHOOSE_PRISMA[index])} ))} @@ -168,8 +178,17 @@ export function getKyselyPageContent() { 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.

    -
    - +
    +
    ))} diff --git a/apps/site/src/app/compare/table-styles.ts b/apps/site/src/app/compare/table-styles.ts index 93fd5aac65..7cf2e5cb8b 100644 --- a/apps/site/src/app/compare/table-styles.ts +++ b/apps/site/src/app/compare/table-styles.ts @@ -1,23 +1,32 @@ /** Table cell padding comes from `.answer-prose`; these set the frame, type and column widths. */ export const TABLE_FRAME = - "overflow-x-auto rounded-xl border border-black/[0.08] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"; -export const TH_COL = "bg-neutral-50 text-sm font-semibold text-primary"; -export const TH_ROW = "bg-background text-sm font-semibold text-primary"; + "compare-table-frame relative overflow-x-auto max-sm:overflow-visible rounded-xl border border-black/[0.08] 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-neutral-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"; /** * 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 on phones. */ +/** Keeps each row's label in view while the table scrolls sideways from `sm` up. */ export const STICKY = - "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-black/[0.06]"; + "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-black/[0.06]"; -export const QUIET_NOTE = "!text-sm !leading-6 !text-muted-foreground"; +export const QUIET_NOTE = "max-w-[32rem] !text-sm !leading-6 !text-muted-foreground"; export const QUIET_LINK = - "underline underline-offset-4 decoration-black/15 transition-colors hover:text-foreground hover:decoration-black/50"; + "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 b31be99626..54de5e8748 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; @@ -610,6 +610,7 @@ Unlayered so it beats Tailwind's base layer; disabled for reduced motion. */ html { scroll-behavior: smooth; + scroll-padding-top: 6rem; } @media (prefers-reduced-motion: reduce) { @@ -618,6 +619,86 @@ 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 }`. + */ +@media (max-width: 639px) { + .answer-prose .compare-stack table { + display: block; + min-width: 0; + 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 41bfa3a711..e6564661db 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} +
    diff --git a/apps/site/src/components/answer-pages/answer-page.tsx b/apps/site/src/components/answer-pages/answer-page.tsx index 8d99fae954..345c0302c9 100644 --- a/apps/site/src/components/answer-pages/answer-page.tsx +++ b/apps/site/src/components/answer-pages/answer-page.tsx @@ -112,7 +112,7 @@ export function AnswerPage({ page }: { page: AnswerPageContent }) {
    -
    +
    diff --git a/apps/site/src/components/ui/accordion.tsx b/apps/site/src/components/ui/accordion.tsx index 3e857661be..cf20c1896c 100644 --- a/apps/site/src/components/ui/accordion.tsx +++ b/apps/site/src/components/ui/accordion.tsx @@ -103,13 +103,13 @@ function AccordionTrigger({ svg]:rotate-180", + "focus-visible:border-ring focus-visible:ring-ring flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180 motion-reduce:transition-none", className, )} {...props} > {children} - + ); @@ -153,7 +153,7 @@ function AccordionContent({ // changes what is in the DOM, not what a reader sees. forceMount inert={!isOpen} - className="data-[state=closed]:h-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" + className="data-[state=closed]:h-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm motion-reduce:animate-none" style={{ "--radix-accordion-content-height": `${height}px`, ...style } as React.CSSProperties} {...props} > diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index 39f6a553b1..0311cdef79 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -9,11 +9,25 @@ function getComparison(slug: ComparisonSlug) { return { ...entry, path: `/compare/${slug}` }; } -/** Describe a comparison as an article in social metadata. */ +/** Describe a comparison as an article in social metadata, and advertise its Markdown. */ export function getComparisonMetadata(slug: ComparisonSlug) { const page = getComparison(slug); const metadata = createPageMetadata({ ...page, ogKicker: "Compare", ogAccent: "cyan" }); - return { ...metadata, openGraph: { ...metadata.openGraph, type: "article" as const } }; + const baseUrl = getBaseUrl(); + const types: Record = { + "text/markdown": new URL(`${page.path}.md`, baseUrl).toString(), + }; + if (slug === "kysely") { + types["application/json"] = new URL(`${page.path}.json`, baseUrl).toString(); + } + return { + ...metadata, + alternates: { + ...metadata.alternates, + types, + }, + openGraph: { ...metadata.openGraph, type: "article" as const }, + }; } /** Describe the comparison article and its directory hierarchy without inventing publication dates. */ diff --git a/apps/site/src/lib/markdown-pages.ts b/apps/site/src/lib/markdown-pages.ts index 88f33f46b2..630bf72c66 100644 --- a/apps/site/src/lib/markdown-pages.ts +++ b/apps/site/src/lib/markdown-pages.ts @@ -46,6 +46,10 @@ export type MarkdownPage = { subheadline: string; /** Everything after the subheadline. */ renderBody: () => string; + /** Human-readable check month, printed at the top of the Markdown. */ + checked?: string; + /** ISO date the page was last checked, independent of git. */ + dateModified?: string; }; export const markdownPages: Record = { @@ -169,12 +173,19 @@ export function getMarkdownPage(path: string): MarkdownPage | undefined { export function renderMarkdownDocument(page: MarkdownPage, baseUrl = getBaseUrl()): string { const llmsTxtUrl = new URL("/llms.txt", baseUrl).toString(); const markdownPath = getMarkdownUrlPath(page.path); + const titleLead = /[.!?]$/.test(page.title) ? page.title : `${page.title}.`; + const freshness = [ + page.checked ? `Checked ${page.checked}.` : "", + page.dateModified ? `dateModified: ${page.dateModified}` : "", + ] + .filter(Boolean) + .join("\n"); return `# ${page.h1} (${page.path}) -> ${page.title}. For the complete Prisma website index, see [llms.txt](${llmsTxtUrl}). A Markdown version of any listed page is available at its \`.md\` URL — this one is \`${markdownPath}\`. +> ${titleLead} For the complete Prisma website index, see [llms.txt](${llmsTxtUrl}). A Markdown version of any listed page is available at its \`.md\` URL — this one is \`${markdownPath}\`. -${page.description} +${freshness ? `${freshness}\n\n` : ""}${page.description} ${page.subheadline} diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index 721a243406..aac54f32a7 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -2,6 +2,7 @@ import { Children, isValidElement, type ReactNode } from "react"; import { CodeBlockTab, CodeBlockTabs, CodeBlockTabsTrigger } from "@prisma/eclipse"; import { AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { AnchorHeading } from "@/app/compare/anchor-heading"; +import { CompareTable, type CompareTableProps } from "@/app/compare/compare-table"; import { ComparisonPricingTable, type PricingTableProps } from "@/app/compare/pricing-table"; import { CtaBurst } from "@/components/sections/cta-burst"; import { RoleKicker } from "@/components/brand/role-kicker"; @@ -89,6 +90,15 @@ export function renderComparisonContent(node: ReactNode, path: string): string { ])}\n\n`; } + if (type === CompareTable) { + const { corner, columns, rows } = props as unknown as CompareTableProps; + const cell = (node: ReactNode) => render(node).trim().replace(/\s+/g, " "); + return `\n\n${table( + [escapeText(corner), ...columns.map((column) => escapeText(column))], + rows.map((row) => [escapeText(row.label), ...row.cells.map(cell)]), + )}\n\n`; + } + if (type === ComparisonPricingTable) { const { corner, columns, rows, note } = props as unknown as PricingTableProps; const cell = (node: ReactNode) => render(node).trim().replace(/\s+/g, " "); @@ -148,5 +158,7 @@ export function renderComparisonContent(node: ReactNode, path: string): string { return render(node) .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") + .replace(/(`[^`]+`)\\([.])/g, "$1$2") + .replace(/(\]\([^)]+\))\\([.])/g, "$1$2") .trim(); } diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 87ef759813..90c2279938 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -1,9 +1,15 @@ import * as convex from "@/app/compare/convex/_content"; import * as kysely from "@/app/compare/kysely/_content"; +import { getKyselyArticleFields, getKyselyFacts } from "@/app/compare/kysely/facts"; +import { GET as getKyselyFactsResponse } from "@/app/compare/kysely.json/route"; import * as mongoose from "@/app/compare/mongoose/_content"; +import { COMPARISON_CTA, ORM_COMPARISON_CTA } from "@/app/compare/_content"; +import { CompareTable } from "@/app/compare/compare-table"; +import { ComparisonPricingTable } from "@/app/compare/pricing-table"; import { stripFaqAnswerMarkup } from "@/lib/faq-answer"; import assert from "node:assert/strict"; import test from "node:test"; +import { Children, isValidElement, type ReactNode } from "react"; import { COMPARISONS, COMPARISON_PATHS } from "@/app/compare/comparisons"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { getAgentMarkdownRewritePathname } from "@/lib/agent-markdown"; @@ -111,6 +117,7 @@ test("Kysely Markdown carries the byline and each code tab as a labelled TypeScr ); assert.ok(markdown.includes(`**Kysely**\n\n\`\`\`ts\n${kysely.SAME_QUERY.kysely}\n\`\`\``)); assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${kysely.SAME_QUERY.prisma}\n\`\`\``)); + assert.ok(markdown.includes(`**SQL sent**\n\n\`\`\`sql\n${kysely.SAME_QUERY.sql}\n\`\`\``)); }); test("a table without rows does not interrupt the comparison rendition", () => { @@ -174,8 +181,9 @@ test("kysely Markdown keeps yes and no marks out of the table, and anchored head hosted.startsWith("| Hosted database | None. Kysely is a library. | [Prisma Postgres]"), ); assert.doesNotMatch(hosted, /\b(Yes|No)\b/); - assert.ok(markdown.includes("\n## When should I choose Prisma ORM or Kysely?\n")); - assert.ok(markdown.includes("\n### One contract for queries and migrations\n")); + assert.ok(markdown.includes("\n## When should I choose Kysely or Prisma ORM?\n")); + assert.ok(markdown.includes("\n### A model API and a SQL query builder\n")); + assert.ok(markdown.includes("\n### A migration plan that does not need the database\n")); assert.doesNotMatch(markdown, /Copy link|Copied/); }); @@ -186,7 +194,7 @@ test("kysely Markdown carries the database matrix and the task table as Markdown assert.ok(markdown.includes("\n## Which databases do Prisma ORM and Kysely support?\n")); assert.ok(markdown.includes("| Database | Kysely | Prisma ORM 8 | Prisma ORM 7 |")); for (const row of kysely.DATABASE_MATRIX) { - const cells = [row.kysely, row.orm8, row.orm7].map((status) => status ?? "Not supported"); + const cells = [row.kysely, row.orm8, row.orm7].map((status) => status ?? "No"); assert.ok( markdown.includes(`| ${row.database} | ${cells.join(" | ")} |`), `missing matrix row: ${row.database}`, @@ -205,26 +213,18 @@ test("kysely Markdown carries the database matrix and the task table as Markdown } }); -test("kysely Markdown renders the cards as headings and the fit columns as headings and lists", () => { +test("kysely Markdown renders the remaining cards and drops the fit columns", () => { const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); + assert.equal(kysely.APART.length, 2); for (const card of kysely.APART) { assert.ok(markdown.includes(`\n### ${card.title}\n`), `missing card heading: ${card.title}`); } - for (const [heading, items] of [ - ["When is Kysely the better fit?", kysely.KYSELY_FITS], - ["When is Prisma ORM the better fit?", kysely.PRISMA_FITS], - ] as const) { - const section = markdown.split(`\n## ${heading}\n`)[1]?.split("\n## ")[0]; - assert.ok(section, `missing column heading: ${heading}`); - for (const item of items) { - assert.ok( - plainMarkdown(section).includes(`- ${stripFaqAnswerMarkup(item)}`), - `missing list item under ${heading}: ${item}`, - ); - } - } + assert.doesNotMatch(markdown, /When is Kysely the better fit\?/); + assert.doesNotMatch(markdown, /When is Prisma ORM the better fit\?/); + assert.doesNotMatch(markdown, /One contract for queries and migrations/); + assert.doesNotMatch(markdown, /A contract from tables you already have/); }); test("kysely Markdown carries pricing as a table followed by the free tier note", () => { @@ -247,3 +247,120 @@ test("kysely Markdown carries pricing as a table followed by the free tier note" ); assert.doesNotMatch(markdown, /pricing page, October 2026/); }); + +test("kysely Markdown opens with the check date and does not add a period after the title's question mark", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes("Checked October 2026.")); + assert.ok(markdown.includes(`dateModified: ${kysely.KYSELY_UPDATED.iso}`)); + assert.doesNotMatch(markdown, /builder\?\./); + assert.doesNotMatch(markdown, /`[^`\n]+`\\\./); + assert.doesNotMatch(markdown, /\]\([^)]+\)\\\./); +}); + +test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () => { + assert.ok( + COMPARISON_CTA.checks.some((item) => item.label === "Trusted by 500K+ developers globally"), + ); + assert.equal(ORM_COMPARISON_CTA.checks, COMPARISON_CTA.checks); + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes("Trusted by 500K+ developers globally")); + assert.ok(markdown.includes("\n## Add Prisma ORM to an existing project\n")); + assert.ok(markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)")); + assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); +}); + +test("kysely Markdown carries the switch types, contract, and four commands", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok( + markdown.includes(`**Kysely types**\n\n\`\`\`ts\n${kysely.SWITCH_KYSELY_TYPES}\n\`\`\``), + ); + assert.ok( + markdown.includes(`**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``), + ); + assert.ok(markdown.includes(`\`\`\`bash\n${kysely.SWITCH_COMMANDS}\n\`\`\``)); +}); + +test("kysely JSON facts carry a source and dateChecked on every value", async () => { + const facts = getKyselyFacts(); + assert.equal(facts.path, "/compare/kysely"); + assert.equal(facts.dateChecked, kysely.KYSELY_UPDATED.iso); + assert.equal(facts.tools.kysely.softwareVersion, "0.29.7"); + assert.equal(facts.features.length, kysely.COMPARE_ROWS.length); + for (const row of [...facts.features, ...facts.pricing]) { + assert.match(row.kysely.source, /^https?:\/\//); + assert.match(row.prisma.source, /^https?:\/\//); + assert.equal(row.kysely.dateChecked, facts.dateChecked); + assert.equal(row.prisma.dateChecked, facts.dateChecked); + } + for (const row of facts.databases) { + for (const side of [row.kysely, row.orm8, row.orm7] as const) { + assert.match(side.source, /^https?:\/\//); + assert.equal(side.dateChecked, facts.dateChecked); + assert.ok(side.value === "No" || typeof side.value === "string"); + } + } + const response = getKyselyFactsResponse(); + assert.match(response.headers.get("content-type") ?? "", /^application\/json/); + assert.deepEqual(await response.json(), facts); + + const fields = getKyselyArticleFields(); + assert.equal(fields.datePublished, kysely.KYSELY_PUBLISHED.iso); + assert.equal(fields.dateModified, kysely.KYSELY_UPDATED.iso); + assert.equal(fields.about[0]?.name, "Kysely"); + assert.equal(fields.about[1]?.name, "Prisma ORM"); +}); + +test("kysely metadata advertises Markdown and JSON alternates", () => { + const types = getComparisonMetadata("kysely").alternates?.types; + assert.ok(types); + assert.match(types["text/markdown"] ?? "", /\/compare\/kysely\.md$/); + assert.match(types["application/json"] ?? "", /\/compare\/kysely\.json$/); +}); + +function dataLabels(node: ReactNode): string[] { + const labels: string[] = []; + function walk(value: ReactNode) { + if (!isValidElement<{ children?: ReactNode; "data-label"?: string }>(value)) return; + if (typeof value.props["data-label"] === "string") labels.push(value.props["data-label"]); + Children.forEach(value.props.children, walk); + } + walk(node); + return labels; +} + +test("stacked comparison tables label every cell for the phone card layout", () => { + const feature = CompareTable({ + labelledBy: "feature-table", + corner: "Feature", + columns: ["Kysely", "Prisma ORM"], + minWidth: "40rem", + rows: [{ label: "Library price", cells: ["Free. MIT license.", "Free. Apache License 2.0."] }], + }); + assert.deepEqual(dataLabels(feature), ["Kysely", "Prisma ORM"]); + + const pricing = ComparisonPricingTable({ + labelledBy: "pricing", + corner: "Cost", + columns: [ + { label: "Kysely", id: "pricing-kysely" }, + { label: "Prisma ORM", id: "pricing-prisma" }, + ], + rows: [{ label: "Library license and cost", cells: ["Free, MIT", "Free, Apache 2.0"] }], + }); + assert.deepEqual(dataLabels(pricing), ["Kysely", "Prisma ORM"]); +}); + +test("same-query line groups stay inside each tab's line count", () => { + const count = (code: string) => code.split("\n").length; + for (const group of kysely.SAME_QUERY_LINE_GROUPS) { + for (const line of group.kysely) assert.ok(line >= 0 && line < count(kysely.SAME_QUERY.kysely)); + for (const line of group.prisma) assert.ok(line >= 0 && line < count(kysely.SAME_QUERY.prisma)); + for (const line of group.sql) assert.ok(line >= 0 && line < count(kysely.SAME_QUERY.sql)); + } +}); diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index bf40ddef60..30918158cc 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -8,6 +8,7 @@ import { type ComparisonSlug, } from "@/app/compare/comparisons"; import { getConvexPageContent } from "@/app/compare/convex/_sections"; +import { CHECKED as KYSELY_CHECKED, KYSELY_UPDATED } from "@/app/compare/kysely/_content"; import { getKyselyPageContent } from "@/app/compare/kysely/_sections"; import { getMongoosePageContent } from "@/app/compare/mongoose/_sections"; import type { MarkdownPage } from "@/lib/markdown-pages"; @@ -56,6 +57,9 @@ export function getComparisonMarkdownPages(): Record blocks.slice(2).join("\n\n"), + ...(entry.slug === "kysely" + ? { checked: KYSELY_CHECKED, dateModified: KYSELY_UPDATED.iso } + : {}), } satisfies MarkdownPage, ]; }), From b4c3e52ffb5798ce1bc63886befae6fe4ae24661 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 10:57:35 +0000 Subject: [PATCH 19/52] Fix Kysely compare OG image types for the site build Read the OG image URL from the concrete page metadata instead of the article openGraph union, which does not expose images to TypeScript. Co-authored-by: rensburg --- apps/site/src/app/compare/kysely/page.tsx | 17 ++++++----------- apps/site/src/lib/comparison-metadata.ts | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 6957f21e1a..e5fd579ca8 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -1,6 +1,10 @@ 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 { getBaseUrl } from "@/lib/url"; import { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE } from "./_content"; @@ -20,15 +24,6 @@ export const metadata = getComparisonMetadata("kysely"); const faqStructuredData = createFaqStructuredData(KYSELY_PATH, [...FAQ], KYSELY_TITLE); -function ogImageUrl() { - const images = getComparisonMetadata("kysely").openGraph?.images; - const first = Array.isArray(images) ? images[0] : images; - if (!first) return undefined; - if (typeof first === "string") return first; - if ("url" in first) return typeof first.url === "string" ? first.url : first.url.toString(); - return undefined; -} - /** The shared comparison graph, with this page's author, dates, image, and tool entities. */ function articleStructuredData() { const base = getComparisonStructuredData("kysely"); @@ -39,7 +34,7 @@ function articleStructuredData() { { ...article, ...getKyselyArticleFields(), - image: ogImageUrl(), + image: getComparisonOgImageUrl("kysely"), author: { "@type": "Person", name: KYSELY_AUTHOR.name, diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index 0311cdef79..b6204404c8 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -26,10 +26,30 @@ export function getComparisonMetadata(slug: ComparisonSlug) { ...metadata.alternates, types, }, - openGraph: { ...metadata.openGraph, type: "article" as const }, + openGraph: { + ...metadata.openGraph, + type: "article" as const, + }, }; } +/** OG image URL from the comparison's page metadata. */ +export function getComparisonOgImageUrl(slug: ComparisonSlug) { + const page = getComparison(slug); + const metadata = createPageMetadata({ ...page, ogKicker: "Compare", ogAccent: "cyan" }); + const og = metadata.openGraph; + if (!og || !("images" in og) || og.images == null) { + throw new Error(`Comparison metadata is missing an OG image: ${page.path}`); + } + const first = Array.isArray(og.images) ? og.images[0] : og.images; + if (typeof first === "string") return first; + if (first instanceof URL) return first.toString(); + if (typeof first === "object" && "url" in first) { + return typeof first.url === "string" ? first.url : first.url.toString(); + } + throw new Error(`Comparison OG image is not a URL: ${page.path}`); +} + /** Describe the comparison article and its directory hierarchy without inventing publication dates. */ export function getComparisonStructuredData(slug: ComparisonSlug) { const page = getComparison(slug); From 7d311ef385309053cb1b48ffb33081eac4a8e5ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 11:04:38 +0000 Subject: [PATCH 20/52] Stop Kysely compare tables from widening the phone layout Stacked rows were still as wide as the desktop minWidth because that value was an inline style. Drive min-width from a CSS variable and force it to 0 below 640px so the page no longer scrolls sideways. Co-authored-by: rensburg --- apps/site/src/app/compare/compare-table.tsx | 5 ++++- apps/site/src/app/compare/pricing-table.tsx | 4 +++- apps/site/src/app/globals.css | 7 +++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/site/src/app/compare/compare-table.tsx b/apps/site/src/app/compare/compare-table.tsx index 9e4bce04e7..46603dcb46 100644 --- a/apps/site/src/app/compare/compare-table.tsx +++ b/apps/site/src/app/compare/compare-table.tsx @@ -31,7 +31,10 @@ export function CompareTable({ labelledBy, corner, columns, rows, minWidth }: Co tabIndex={0} style={SCROLL_HINT} > -
    From 93eb399baa6a36b147fc38272753aa670667d3bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 21:29:44 +0000 Subject: [PATCH 06/52] site: let Kysely code spans wrap and drop a repeated opener Code spans wrap at spaces and keep their padding on each line, so long commands no longer leave short lines on phones. The Where Kysely works well section no longer repeats the feature table's first sentence. Co-authored-by: rensburg --- apps/site/src/app/compare/kysely/_sections.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index fc0932d7fe..247fea6df6 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -35,10 +35,10 @@ const QUIET_LINK = * 32px between h3 groups and 8px under each h3. 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 never break inside a command. + * `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]:whitespace-nowrap [&_code]:rounded [&_code]:border-0 [&_code]:bg-black/[0.045] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_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"; + "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.5 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_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 GROUPS = "flex flex-col gap-8"; const GROUP = "flex flex-col gap-2"; @@ -220,7 +220,7 @@ export function getKyselyPageContent() {

    Where Kysely works well

    - Kysely is a SQL query builder. Its{" "} + The Kysely{" "} getting started guide {" "} From 114da84606ffabaf3dc53f8bd178c717a9452a9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 21:55:19 +0000 Subject: [PATCH 07/52] site: move the Kysely disclosure into a quiet note below the hero Co-authored-by: rensburg --- .../site/src/app/compare/kysely/_sections.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 247fea6df6..c5b8c98a9d 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { Info } from "lucide-react"; import { Accordion, AccordionContent, @@ -128,14 +129,6 @@ export function getKyselyPageContent() { starts from SQL: you write each query as a fluent TypeScript builder over table types you keep yourself.

    -

    - Prisma makes Prisma ORM; Kysely is an independent open-source project. Figures about Kysely - come from its own pages, listed under{" "} - - Sources - {" "} - and checked {CHECKED}. -

    Get started free @@ -146,6 +139,19 @@ export function getKyselyPageContent() {
    ,
    +

    When should I choose Prisma ORM or Kysely

    From 50eeb2918a42a2cf6a6e7a985de48a7ac4fdd556 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 22:20:33 +0000 Subject: [PATCH 08/52] site: show the same query in Kysely and Prisma ORM on /compare/kysely Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 1 + apps/site/src/app/compare/kysely/_content.ts | 25 +++++++ .../site/src/app/compare/kysely/_sections.tsx | 51 ++++++++++++++ .../site/src/app/compare/kysely/highlight.tsx | 70 +++++++++++++++++++ apps/site/src/app/compare/kysely/page.tsx | 13 +++- .../src/lib/markdown/comparison-content.ts | 29 +++++++- .../src/lib/markdown/comparisons.test.tsx | 8 +++ 7 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 apps/site/src/app/compare/kysely/highlight.tsx diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index e393f26d4e..f3f41388a0 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -11,6 +11,7 @@ const PROSE = const TOC: Record = { kysely: [ { id: "choose", label: "When should I choose Prisma ORM or Kysely" }, + { id: "same-query", label: "The same query in both" }, { id: "feature-table", label: "What each one supports" }, { id: "where-kysely", label: "Where Kysely works well" }, { id: "where-prisma", label: "Where Prisma ORM fits better" }, diff --git a/apps/site/src/app/compare/kysely/_content.ts b/apps/site/src/app/compare/kysely/_content.ts index 28d3d1a3b9..731087369d 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -21,6 +21,31 @@ export const CHOOSE_KYSELY = [ "You need a built-in dialect 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();`, +} as const; + export type CompareRow = { feature: string; kysely: string; diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index c5b8c98a9d..c0142be469 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,5 +1,12 @@ import Link from "next/link"; import { Info } from "lucide-react"; +import { + CodeBlock, + CodeBlockTab, + CodeBlockTabs, + CodeBlockTabsList, + CodeBlockTabsTrigger, +} from "@prisma/eclipse"; import { Accordion, AccordionContent, @@ -10,6 +17,7 @@ import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { COMPARISON_CTA } from "../_content"; import { CompareArticle } from "../article"; +import { highlight } from "./highlight"; import { QuoteCarousel } from "./quote-carousel"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { @@ -22,6 +30,7 @@ import { KYSELY_FITS, KYSELY_TITLE, PRISMA_FITS, + SAME_QUERY, WRONG_CHOICE, } from "./_content"; @@ -65,6 +74,18 @@ const STICKY = "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-black/[0.06]"; const TD = "text-sm leading-6 text-foreground"; +/** + * 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 rounded-[0.625rem] border-black/[0.08] bg-white"; +const CODE_TAB_LIST = "flex flex-row overflow-x-auto px-2"; +const CODE_TAB = + "relative inline-flex items-center px-2 py-2.5 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 = + "my-0 rounded-none border-black/[0.08] bg-white [&>div]:py-3.5 [&_button]:text-muted-foreground"; + const PROOF = TESTIMONIALS.filter( (item) => item.name === "Ron Cohen" || item.name === "Omar López", ); @@ -81,6 +102,7 @@ const SOURCES = [ { label: "Getting started", href: "https://kysely.dev/docs/getting-started" }, { label: "kysely on npm", href: "https://www.npmjs.com/package/kysely" }, { label: "Migrations", href: "https://kysely.dev/docs/migrations" }, + { label: "Relations recipe", href: "https://kysely.dev/docs/recipes/relations" }, { label: "MIT license", href: "https://github.com/kysely-org/kysely/blob/master/LICENSE" }, ], }, @@ -89,6 +111,7 @@ const SOURCES = [ links: [ { label: "Prisma ORM docs", href: "/docs/orm" }, { label: "Supported databases", href: "/docs/orm/supported-databases" }, + { 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" }, @@ -178,6 +201,34 @@ export function getKyselyPageContent() {
    +
    +

    The same query in both

    +

    + 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. +

    + + + + Kysely + + + Prisma ORM + + + {(["kysely", "prisma"] as const).map((tool) => ( + + {highlight(SAME_QUERY[tool])} + + ))} + +
    +

    What each one supports

    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 0000000000..45e70dc773 --- /dev/null +++ b/apps/site/src/app/compare/kysely/highlight.tsx @@ -0,0 +1,70 @@ +import type { CSSProperties } from "react"; +import { createHighlighterCoreSync } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import githubDark from "shiki/themes/github-dark.mjs"; +import githubLight from "shiki/themes/github-light.mjs"; +import typescript from "shiki/langs/typescript.mjs"; + +/** + * The docs' Shiki themes with `defaultColor: false`, so tokens carry + * `--shiki-light` / `--shiki-dark` and `globals.css` picks the active one. + * Synchronous, because the same content tree also renders to Markdown. + */ +const highlighter = createHighlighterCoreSync({ + themes: [githubLight, githubDark], + langs: [typescript], + 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", +}; + +/** A highlighted `pre` with the docs' code block layout: one flex row per line. */ +export function highlight(code: string) { + const { tokens } = highlighter.codeToTokens(code, { + lang: "typescript", + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + }); + return ( +

    +      
    +        {tokens.flatMap((line, index) => [
    +          index > 0 ? "\n" : null,
    +          
    +            {line.length === 0
    +              ? " "
    +              : line.map((token) => (
    +                  
    +                    {token.content}
    +                  
    +                ))}
    +          ,
    +        ])}
    +      
    +    
    + ); +} diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 74d6e07bd9..5130dadefd 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -1,9 +1,18 @@ +import localFont from "next/font/local"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { FAQ, KYSELY_PATH, KYSELY_TITLE } from "./_content"; import { KyselyPage } from "./_sections"; +/** The docs' code face, loaded here rather than in `highlight.tsx` because tests import that file outside Next. */ +const monaSansMono = localFont({ + src: "../../../../../../packages/eclipse/src/static/fonts/MonaSansMonoVF[wght].woff2", + variable: "--font-code", + display: "swap", + weight: "200 900", +}); + export const metadata = getComparisonMetadata("kysely"); const faqStructuredData = createFaqStructuredData(KYSELY_PATH, [...FAQ], KYSELY_TITLE); @@ -14,7 +23,9 @@ export default function Page() { <> - +
    + +
    ); } diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index 481643d768..a04ab8bb53 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -1,4 +1,5 @@ import { Children, isValidElement, type ReactNode } from "react"; +import { CodeBlockTab, CodeBlockTabs, CodeBlockTabsTrigger } from "@prisma/eclipse"; import { AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { CtaBurst } from "@/components/sections/cta-burst"; import { RoleKicker } from "@/components/brand/role-kicker"; @@ -38,7 +39,33 @@ export function renderComparisonContent(node: ReactNode, path: string): string { if (!isValidElement(value)) return ""; const { type, props } = value; if (props["aria-hidden"] === true || type === RoleKicker || type === Texture) return ""; - if (type === "pre") return `\n\n\`\`\`\n${codeText(props.children)}\n\`\`\`\n\n`; + if (type === "pre") { + const language = typeof props["data-language"] === "string" ? props["data-language"] : ""; + return `\n\n\`\`\`${language}\n${codeText(props.children)}\n\`\`\`\n\n`; + } + if (type === CodeBlockTabs) { + const labels = new Map(); + const panels: ReactNode[] = []; + const collect = (nodes: ReactNode) => + Children.forEach(nodes, (child) => { + if (!isValidElement(child)) return; + if (child.type === CodeBlockTabsTrigger) { + labels.set(child.props.value, codeText(child.props.children)); + } else if (child.type === CodeBlockTab) { + panels.push(child); + } else { + collect(child.props.children); + } + }); + collect(props.children); + return panels + .map((panel) => { + if (!isValidElement(panel)) return ""; + const label = labels.get(panel.props.value); + return `\n\n${label ? `**${escapeText(label)}**\n\n` : ""}${render(panel.props.children).trim()}\n\n`; + }) + .join(""); + } if (type === "code") { const text = codeText(props.children); const ticks = "`".repeat( diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 372d08b227..8e295e8af2 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -99,6 +99,14 @@ test("comparison Markdown preserves literal code and escapes table pipes", () => assert.ok(markdown.includes("```\nSELECT '';\n```")); }); +test("Kysely Markdown carries each code tab as a labelled TypeScript fence", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes(`**Kysely**\n\n\`\`\`ts\n${kysely.SAME_QUERY.kysely}\n\`\`\``)); + assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${kysely.SAME_QUERY.prisma}\n\`\`\``)); +}); + test("a table without rows does not interrupt the comparison rendition", () => { assert.equal(renderComparisonContent(, "/compare/convex"), ""); }); From c813ed68e73bef5ae2d35f14b6d4e52b7365555b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 22:20:36 +0000 Subject: [PATCH 09/52] site: add Martin Janse van Rensburg's byline and Article author to /compare/kysely Co-authored-by: rensburg --- apps/site/src/app/compare/kysely/_content.ts | 9 ++++ .../site/src/app/compare/kysely/_sections.tsx | 50 ++++++++++++++----- apps/site/src/app/compare/kysely/page.tsx | 26 +++++++++- .../src/lib/markdown/comparisons.test.tsx | 7 ++- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_content.ts b/apps/site/src/app/compare/kysely/_content.ts index 731087369d..57f930726b 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -7,6 +7,15 @@ export const KYSELY_DESCRIPTION = 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_UPDATED = { iso: "2026-10-17", label: "October 2026" } as const; + export const CHOOSE_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.", diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index c0142be469..4b9afc4837 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -27,8 +27,10 @@ import { CHOOSE_PRISMA, COMPARE_ROWS, FAQ, + KYSELY_AUTHOR, KYSELY_FITS, KYSELY_TITLE, + KYSELY_UPDATED, PRISMA_FITS, SAME_QUERY, WRONG_CHOICE, @@ -162,19 +164,43 @@ export function getKyselyPageContent() { ,
    - + +

    When should I choose Prisma ORM or Kysely

    diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 5130dadefd..1c6862405a 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -2,7 +2,8 @@ import localFont from "next/font/local"; import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, 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 { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE, KYSELY_UPDATED } from "./_content"; import { KyselyPage } from "./_sections"; /** The docs' code face, loaded here rather than in `highlight.tsx` because tests import that file outside Next. */ @@ -17,11 +18,32 @@ export const metadata = getComparisonMetadata("kysely"); const faqStructuredData = createFaqStructuredData(KYSELY_PATH, [...FAQ], KYSELY_TITLE); +/** The shared comparison graph, with this page's author and update date on its Article. */ +function articleStructuredData() { + const base = getComparisonStructuredData("kysely"); + const [article, ...rest] = base["@graph"]; + return { + ...base, + "@graph": [ + { + ...article, + dateModified: KYSELY_UPDATED.iso, + author: { + "@type": "Person", + name: KYSELY_AUTHOR.name, + url: new URL(KYSELY_AUTHOR.path, getBaseUrl()).toString(), + }, + }, + ...rest, + ], + }; +} + /** Render the comparison and its article, breadcrumb and FAQ structured data. */ export default function Page() { return ( <> - +
    diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 8e295e8af2..64d850108e 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -99,10 +99,15 @@ test("comparison Markdown preserves literal code and escapes table pipes", () => assert.ok(markdown.includes("```\nSELECT '';\n```")); }); -test("Kysely Markdown carries each code tab as a labelled TypeScript fence", () => { +test("Kysely Markdown carries the byline and each code tab as a labelled TypeScript fence", () => { const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); + assert.ok( + markdown.includes( + `By [${kysely.KYSELY_AUTHOR.name}](${kysely.KYSELY_AUTHOR.path}) · Updated ${kysely.KYSELY_UPDATED.label}`, + ), + ); assert.ok(markdown.includes(`**Kysely**\n\n\`\`\`ts\n${kysely.SAME_QUERY.kysely}\n\`\`\``)); assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${kysely.SAME_QUERY.prisma}\n\`\`\``)); }); From de41e54db2fe99f655dcb355d08774f16aab725d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 22:23:56 +0000 Subject: [PATCH 10/52] site: stack the Kysely byline beside the photo on phones Co-authored-by: rensburg --- .../site/src/app/compare/kysely/_sections.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 4b9afc4837..4af79dc751 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -173,18 +173,20 @@ export function getKyselyPageContent() { height={32} className="size-8 rounded-md bg-neutral-100" /> - - By{" "} - - {KYSELY_AUTHOR.name} - - {" "} - ·{" "} - - Updated + + + By{" "} + + {KYSELY_AUTHOR.name} + + {" "} + ·{" "} + + Updated +

    From 72dad05bf64488ad2d40ee144568091f1f19ca21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 22:41:54 +0000 Subject: [PATCH 13/52] Kysely compare: yes/no table marks, heading copy links, round avatar, Kysely 0.29.7 Co-authored-by: rensburg --- apps/site/src/app/compare/anchor-heading.tsx | 68 +++++++++++++ apps/site/src/app/compare/kysely/_content.ts | 9 +- .../site/src/app/compare/kysely/_sections.tsx | 95 +++++++++++++++---- .../src/lib/markdown/comparison-content.ts | 4 + .../src/lib/markdown/comparisons.test.tsx | 17 ++++ .../src/lib/markdown/markdown-parity.test.ts | 9 +- 6 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 apps/site/src/app/compare/anchor-heading.tsx 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 0000000000..09d00b3f19 --- /dev/null +++ b/apps/site/src/app/compare/anchor-heading.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Check, Link as LinkIcon } from "lucide-react"; + +/** + * A section heading with a copy-link button in the left gutter, following the + * copy-link pattern in Eclipse's accordion. The button is absolutely positioned + * so it never moves the heading, and only appears on devices that can hover. + */ +export function AnchorHeading({ + level, + id, + className, + children, +}: { + level: 2 | 3; + id: string; + className?: string; + children: ReactNode; +}) { + const Tag = level === 2 ? "h2" : "h3"; + const [copied, setCopied] = useState(false); + const timer = useRef>(undefined); + + 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), 1600); + } + + return ( + + {children} + + + + + ); +} diff --git a/apps/site/src/app/compare/kysely/_content.ts b/apps/site/src/app/compare/kysely/_content.ts index 57f930726b..40a95b4c40 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -59,6 +59,8 @@ export type CompareRow = { feature: string; kysely: string; prisma: string; + /** Only for rows whose cells answer a plain yes or no. */ + marks?: { kysely: "yes" | "no"; prisma: "yes" | "no" }; }; /** Columns run Feature, Kysely, Prisma ORM: alphabetical by product. */ @@ -71,7 +73,7 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ { 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.", }, @@ -104,6 +106,7 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ { feature: "Hosted database", kysely: "None. Kysely is a library.", + marks: { kysely: "no", prisma: "yes" }, 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.", }, @@ -125,18 +128,22 @@ export const PRISMA_FITS = [ export const APART = [ { + id: "one-contract", 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.", }, { + 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: "contract-from-tables", 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.", }, { + 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.", }, diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 4af79dc751..204a8ca286 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; -import { Info } from "lucide-react"; +import type { ReactNode } from "react"; +import { Check, Info, Minus } from "lucide-react"; import { CodeBlock, CodeBlockTab, @@ -16,6 +17,7 @@ import { import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { COMPARISON_CTA } from "../_content"; +import { AnchorHeading } from "../anchor-heading"; import { CompareArticle } from "../article"; import { highlight } from "./highlight"; import { QuoteCarousel } from "./quote-carousel"; @@ -76,6 +78,27 @@ const STICKY = "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-black/[0.06]"; const TD = "text-sm leading-6 text-foreground"; +/** + * A quiet yes or no beside a cell's own wording. The icon is cap height and + * centred on the first line; its label is an attribute, so Markdown keeps the text alone. + */ +function MarkedCell({ mark, children }: { mark?: "yes" | "no"; children: ReactNode }) { + if (!mark) return children; + const Icon = mark === "yes" ? Check : Minus; + return ( + + + + + {children} + + ); +} + /** * The site does not build the eclipse tokens these components use * (`rounded-square`, `border-stroke-neutral`, `type-text-sm-strong`, the active @@ -171,7 +194,7 @@ export function getKyselyPageContent() { alt="" width={32} height={32} - className="size-8 rounded-md bg-neutral-100" + className="size-8 shrink-0 rounded-full bg-neutral-100 object-cover" /> @@ -204,7 +227,9 @@ export function getKyselyPageContent() {
    -

    When should I choose Prisma ORM or Kysely

    + + When should I choose Prisma ORM or Kysely +
    @@ -230,7 +255,9 @@ export function getKyselyPageContent() {
    -

    The same query in both

    + + The same query in both +

    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. @@ -258,7 +285,9 @@ export function getKyselyPageContent() {

    -

    What each one supports

    + + What each one supports +

    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. @@ -293,8 +322,12 @@ export function getKyselyPageContent() {

    - - + + ))} @@ -303,7 +336,9 @@ export function getKyselyPageContent() {
    -

    Where Kysely works well

    + + Where Kysely works well +

    The Kysely{" "} @@ -320,7 +355,9 @@ export function getKyselyPageContent() {

    -

    Where Prisma ORM fits better

    + + 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{" "} @@ -337,11 +374,15 @@ export function getKyselyPageContent() {

    -

    What sets Prisma ORM apart

    + + What sets Prisma ORM apart +
    {APART.map((item) => (
    -

    {item.title}

    + + {item.title} +

    {renderFaqAnswer(item.body)}

    ))} @@ -349,10 +390,14 @@ export function getKyselyPageContent() {
    -

    Pricing

    + + Pricing +
    -

    When Prisma ORM is the wrong choice

    + + When Prisma ORM is the wrong choice +
      {WRONG_CHOICE.map((item) => (
    • {renderFaqAnswer(item)}
    • @@ -390,7 +439,9 @@ export function getKyselyPageContent() {
    -

    Move from Kysely to Prisma ORM

    + + Move from Kysely to Prisma ORM +
    1. For PostgreSQL, run npx prisma@latest orm init in the app. The{" "} @@ -417,7 +468,9 @@ export function getKyselyPageContent() {
    -

    Move from Prisma ORM to Kysely

    + + Move from Prisma ORM to Kysely +
    1. Keep the database and the connection string.
    2. @@ -441,7 +494,9 @@ export function getKyselyPageContent() {
    -

    What teams on Prisma say

    + + What teams on Prisma say +

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

    {PROOF.map((item) => ( @@ -466,7 +521,9 @@ export function getKyselyPageContent() {
    -

    Frequently asked questions

    + + Frequently asked questions + {FAQ.map((faq, index) => ( diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index a04ab8bb53..8f7d617a91 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -1,6 +1,7 @@ import { Children, isValidElement, type ReactNode } from "react"; import { CodeBlockTab, CodeBlockTabs, CodeBlockTabsTrigger } from "@prisma/eclipse"; import { AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; +import { AnchorHeading } from "@/app/compare/anchor-heading"; import { CtaBurst } from "@/components/sections/cta-burst"; import { RoleKicker } from "@/components/brand/role-kicker"; import { Texture } from "@/components/brand/texture"; @@ -119,6 +120,9 @@ export function renderComparisonContent(node: ReactNode, path: string): string { if (type === "em" || type === "i") return `*${children}*`; if (type === "br") return "\n"; if (type === "blockquote") return `\n\n> ${children.trim().replace(/\n/g, "\n> ")}\n\n`; + if (type === AnchorHeading) { + return `\n\n${"#".repeat(props.level as number)} ${children.trim()}\n\n`; + } if (type === AccordionTrigger) return `\n\n### ${children.trim()}\n\n`; if (type === AccordionContent) return `\n\n${children.trim()}\n\n`; if (typeof type === "string" && /^h[1-6]$/.test(type)) { diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 64d850108e..f5be8888ca 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -29,6 +29,7 @@ for (const entry of COMPARISONS) { const plain = plainMarkdown(markdown); for (const row of content.COMPARE_ROWS) { for (const cell of Object.values(row)) { + if (typeof cell !== "string") continue; assert.ok(plain.includes(stripFaqAnswerMarkup(cell)), `missing table cell: ${cell}`); } } @@ -162,3 +163,19 @@ test("literal block markers stay prose while semantic headings and lists keep th "## Heading\n\n- \\- Literal bullet", ); }); + +test("kysely Markdown keeps yes and no marks out of the table, and anchored headings intact", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const hosted = markdown.split("\n").find((line) => line.startsWith("| Hosted database |")); + assert.ok(hosted); + assert.ok( + hosted.startsWith("| Hosted database | None. Kysely is a library. | [Prisma Postgres]"), + ); + assert.doesNotMatch(hosted, /\b(Yes|No)\b/); + assert.ok(markdown.includes("\n## When should I choose Prisma ORM or Kysely\n")); + assert.ok(markdown.includes("\n### One contract for queries and migrations\n")); + assert.ok(markdown.includes("\n### Prisma ORM and Prisma Postgres\n")); + assert.doesNotMatch(markdown, /Copy link|Copied/); +}); diff --git a/apps/site/src/lib/markdown/markdown-parity.test.ts b/apps/site/src/lib/markdown/markdown-parity.test.ts index 992438b9e0..202170905b 100644 --- a/apps/site/src/lib/markdown/markdown-parity.test.ts +++ b/apps/site/src/lib/markdown/markdown-parity.test.ts @@ -119,10 +119,12 @@ const MIN_LITERAL_HEADINGS: Record = { "/compare/mongoose": 10, }; -const HEADING_PATTERN = /<(h[12])\b[^>]*>([\s\S]*?)<\/\1>/g; +const HEADING_PATTERN = + /<(h[12])\b[^>]*>([\s\S]*?)<\/\1>|]*>([\s\S]*?)<\/AnchorHeading>/g; /** - * The literal text of every `

    `/`

    ` in a source file. + * The literal text of every `

    `/`

    ` in a source file, including level 2 + * ``s. * * Presentational wrappers inside a heading (``, ``) are * flattened, because they carry no meaning in Markdown. A heading whose text @@ -134,7 +136,8 @@ const HEADING_PATTERN = /<(h[12])\b[^>]*>([\s\S]*?)<\/\1>/g; function literalHeadings(source: string): string[] { const headings: string[] = []; - for (const [, , inner] of source.matchAll(HEADING_PATTERN)) { + for (const [, , tagInner, anchorInner] of source.matchAll(HEADING_PATTERN)) { + const inner = tagInner ?? anchorInner; // JSX's explicit whitespace literal, used to keep a space before a tag. let flattened = inner.replace(/\{" "\}/g, " "); From f59a46770fc88f78a49e9061c7b84b0ec724a2a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 22:46:04 +0000 Subject: [PATCH 14/52] Compare article: right-rail TOC with short labels, mobile disclosure, aligned reading column, smaller H2s, one byline-and-note unit Co-authored-by: rensburg --- apps/site/src/app/compare/article-toc.tsx | 106 ++++++++++--- apps/site/src/app/compare/article.tsx | 141 +++++++++--------- .../site/src/app/compare/kysely/_sections.tsx | 16 +- 3 files changed, 159 insertions(+), 104 deletions(-) diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index 894629641c..e8ff104470 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -1,32 +1,40 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; -type TocItem = { id: string; label: string }; +/** 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 heading list with the section being read marked: 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. + * 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. */ -export function ArticleToc({ items }: { items: readonly TocItem[] }) { - const [active, setActive] = useState(items[0]?.id); +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 = items - .map((item) => document.getElementById(item.id)) - .filter((element): element is HTMLElement => element !== null); + 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; @@ -36,7 +44,7 @@ export function ArticleToc({ items }: { items: readonly TocItem[] }) { const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2; if (atBottom) current = headings[headings.length - 1].id; - setActive(current); + setActive(owner.get(current)); }; const schedule = () => { @@ -46,10 +54,10 @@ export function ArticleToc({ items }: { items: readonly TocItem[] }) { pinned.current = null; }; const followHash = () => { - const id = decodeURIComponent(window.location.hash.slice(1)); - if (items.some((item) => item.id === id)) { - pinned.current = id; - setActive(id); + const target = owner.get(decodeURIComponent(window.location.hash.slice(1))); + if (target) { + pinned.current = target; + setActive(target); } else { release(); schedule(); @@ -72,18 +80,31 @@ export function ArticleToc({ items }: { items: readonly TocItem[] }) { }; }, [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 ( -

    - - Where Kysely works well + + Database support -

    - The Kysely{" "} - - getting started guide +

    + 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. A dash means no + support. +

    +
    +
    {row.feature} {renderFaqAnswer(row.kysely)}{renderFaqAnswer(row.prisma)} + {renderFaqAnswer(row.kysely)} + + {renderFaqAnswer(row.prisma)} +
    + + + + + + + + + + {DATABASE_MATRIX.map((row) => ( + + + + + + + ))} + +
    + Database + + Kysely + + Prisma ORM 8 + + Prisma ORM 7 +
    + {row.database} + {databaseStatus(row.kysely)}{databaseStatus(row.orm8)}{databaseStatus(row.orm7)}
    +
    +

    + MariaDB on Kysely is the community{" "} + + kysely-mariadb {" "} - shows selectFrom and where, and lists joins among the other SQL - features. Each built-in dialect needs its driver. + dialect. Sources:{" "} + + Kysely dialects + + ,{" "} + + Prisma ORM 8 supported databases + + , and{" "} + + Prisma ORM 7 supported databases + + , {CHECKED}.

    -
      - {KYSELY_FITS.map((item) => ( -
    • {renderFaqAnswer(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 - {" "} - describe that file as the contract. -

    -
      - {PRISMA_FITS.map((item) => ( -
    • {renderFaqAnswer(item)}
    • - ))} -
    -
    +
    +
    + + Where Kysely works well + +
    +

    + The Kysely{" "} + + 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) => ( +
    • {renderFaqAnswer(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 + {" "} + describe that file as the contract. +

    +
      + {PRISMA_FITS.map((item) => ( +
    • {renderFaqAnswer(item)}
    • + ))} +
    +
    +
    +
    What sets Prisma ORM apart -
    +
    {APART.map((item) => ( -
    - +
    + {item.title} -

    {renderFaqAnswer(item.body)}

    +

    {renderFaqAnswer(item.body)}

    ))}
    @@ -438,6 +572,53 @@ export function getKyselyPageContent() {
    +
    + + Same job, different tool + +

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

    +
    + + + + + + + + + + {SAME_JOB.map((row) => ( + + + + + + ))} + +
    + Task + + Kysely + + Prisma ORM +
    + {row.task} + {renderFaqAnswer(row.kysely)}{renderFaqAnswer(row.prisma)}
    +
    +
    +
    Move from Kysely to Prisma ORM diff --git a/apps/site/src/lib/faq-answer.tsx b/apps/site/src/lib/faq-answer.tsx index 08c6a651a0..6c3e16a45a 100644 --- a/apps/site/src/lib/faq-answer.tsx +++ b/apps/site/src/lib/faq-answer.tsx @@ -25,7 +25,7 @@ import * as React from "react"; * - inline code uses the same `bg-muted` chip as other sections. */ -const LINK_PATTERN = /\[((?:[^[\]\\`]|`[^`]*`)+)\]\((\/[^\s)]*|https?:\/\/[^\s)]+)\)/g; +const LINK_PATTERN = /\[((?:[^[\]\\`]|`[^`]*`)+)\]\((\/[^\s)]*|#[^\s)]+|https?:\/\/[^\s)]+)\)/g; const CODE_PATTERN = /`([^`]+)`/g; function renderCode(label: string, keyPrefix: string): React.ReactNode[] { diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index f5be8888ca..ca66cb1d92 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -179,3 +179,51 @@ test("kysely Markdown keeps yes and no marks out of the table, and anchored head assert.ok(markdown.includes("\n### Prisma ORM and Prisma Postgres\n")); assert.doesNotMatch(markdown, /Copy link|Copied/); }); + +test("kysely Markdown carries the database matrix and the task table as Markdown tables", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes("\n## Database support\n")); + assert.ok(markdown.includes("| Database | Kysely | Prisma ORM 8 | Prisma ORM 7 |")); + for (const row of kysely.DATABASE_MATRIX) { + const cells = [row.kysely, row.orm8, row.orm7].map((status) => status ?? "Not supported"); + assert.ok( + markdown.includes(`| ${row.database} | ${cells.join(" | ")} |`), + `missing matrix row: ${row.database}`, + ); + } + assert.ok(markdown.includes("](/compare/kysely#databases)"), "Databases row links to the matrix"); + + assert.ok(markdown.includes("\n## Same job, different tool\n")); + assert.ok(markdown.includes("| Task | Kysely | Prisma ORM |")); + const plain = plainMarkdown(markdown); + for (const row of kysely.SAME_JOB) { + const line = markdown.split("\n").find((text) => text.startsWith(`| ${row.task} |`)); + assert.ok(line, `missing task row: ${row.task}`); + assert.ok(plain.includes(stripFaqAnswerMarkup(row.kysely)), `missing Kysely cell: ${row.task}`); + assert.ok(plain.includes(stripFaqAnswerMarkup(row.prisma)), `missing Prisma cell: ${row.task}`); + } +}); + +test("kysely Markdown renders the cards as headings and the fit columns as headings and lists", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + for (const card of kysely.APART) { + assert.ok(markdown.includes(`\n### ${card.title}\n`), `missing card heading: ${card.title}`); + } + for (const [heading, items] of [ + ["Where Kysely works well", kysely.KYSELY_FITS], + ["Where Prisma ORM fits better", kysely.PRISMA_FITS], + ] as const) { + const section = markdown.split(`\n## ${heading}\n`)[1]?.split("\n## ")[0]; + assert.ok(section, `missing column heading: ${heading}`); + for (const item of items) { + assert.ok( + plainMarkdown(section).includes(`- ${stripFaqAnswerMarkup(item)}`), + `missing list item under ${heading}: ${item}`, + ); + } + } +}); From 2503602ac9f529e7f42fd190bf5f9f2b4112efe0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 09:19:59 +0000 Subject: [PATCH 16/52] Kysely compare: plain byline above the sources note in its own box Co-authored-by: rensburg --- apps/site/src/app/compare/kysely/_sections.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 0994cc1f4f..5aa83109dc 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -240,8 +240,8 @@ export function getKyselyPageContent() { ,
    -
    -

    +

    +

    - When should I choose Prisma ORM or Kysely + When should I choose Prisma ORM or Kysely?
    @@ -309,7 +296,7 @@ export function getKyselyPageContent() {
    - The same query in both + 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{" "} @@ -339,7 +326,7 @@ export function getKyselyPageContent() {

    - What each one supports + 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 @@ -390,7 +377,7 @@ export function getKyselyPageContent() {

    - Database support + Which databases do Prisma ORM and Kysely support?

    Kysely's dialects next to the two Prisma ORM lines. Prisma ORM 7 is the generally @@ -438,7 +425,7 @@ export function getKyselyPageContent() {

    -

    +

    MariaDB on Kysely is the community{" "} kysely-mariadb @@ -462,7 +449,7 @@ export function getKyselyPageContent() {

    - Where Kysely works well + When is Kysely the better fit?

    @@ -482,7 +469,7 @@ export function getKyselyPageContent() {

    - Where Prisma ORM fits better + When is Prisma ORM the better fit?

    @@ -504,7 +491,7 @@ export function getKyselyPageContent() {

    - What sets Prisma ORM apart + {"What does Prisma ORM do that Kysely doesn't?"}
    {APART.map((item) => ( @@ -525,45 +512,35 @@ export function getKyselyPageContent() {
    - Pricing + How much do Prisma ORM and Kysely cost? -
    - -
    - - Prisma ORM and Prisma Postgres - -

    - Prisma ORM is free under the{" "} - - Apache License 2.0 - - . Prisma Postgres is optional, and it works with any Postgres client, so Prisma ORM is - optional with it. Its{" "} - - Free plan + ({ + 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 {" "} - is $0, with 200,000 operations a month, 2 GB of storage, and 50 databases. Prisma - Compute is separate app hosting. -

    -
    -
    + lists the paid plans. + + } + />
    - When Prisma ORM is the wrong choice + When is Prisma ORM the wrong choice?
      {WRONG_CHOICE.map((item) => ( @@ -574,7 +551,7 @@ export function getKyselyPageContent() {
      - Same job, different tool + 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 @@ -621,7 +598,7 @@ export function getKyselyPageContent() {

      - Move from Kysely to Prisma ORM + How do I switch from Kysely to Prisma ORM?
      1. @@ -650,7 +627,7 @@ export function getKyselyPageContent() {
        - Move from Prisma ORM to Kysely + How do I switch from Prisma ORM to Kysely?
        1. Keep the database and the connection string.
        2. @@ -676,7 +653,7 @@ export function getKyselyPageContent() {
          - What teams on Prisma say + What do teams say about Prisma ORM?

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

          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 0000000000..be550283ba --- /dev/null +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -0,0 +1,90 @@ +import type { ReactNode } from "react"; +import { QUIET_NOTE, SCROLL_HINT, STICKY, TABLE_FRAME, TD, TH_COL, TH_ROW } from "./table-styles"; + +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. 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/table-styles.ts b/apps/site/src/app/compare/table-styles.ts new file mode 100644 index 0000000000..93fd5aac65 --- /dev/null +++ b/apps/site/src/app/compare/table-styles.ts @@ -0,0 +1,23 @@ +/** Table cell padding comes from `.answer-prose`; these set the frame, type and column widths. */ +export const TABLE_FRAME = + "overflow-x-auto rounded-xl border border-black/[0.08] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"; +export const TH_COL = "bg-neutral-50 text-sm font-semibold text-primary"; +export const TH_ROW = "bg-background text-sm font-semibold text-primary"; +export const TD = "text-sm leading-6 text-foreground"; + +/** + * 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. + */ +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 on phones. */ +export const STICKY = + "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-black/[0.06]"; + +export const QUIET_NOTE = "!text-sm !leading-6 !text-muted-foreground"; +export const QUIET_LINK = + "underline underline-offset-4 decoration-black/15 transition-colors hover:text-foreground hover:decoration-black/50"; diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index 8f7d617a91..721a243406 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -2,6 +2,7 @@ import { Children, isValidElement, type ReactNode } from "react"; import { CodeBlockTab, CodeBlockTabs, CodeBlockTabsTrigger } from "@prisma/eclipse"; import { AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { AnchorHeading } from "@/app/compare/anchor-heading"; +import { ComparisonPricingTable, type PricingTableProps } from "@/app/compare/pricing-table"; import { CtaBurst } from "@/components/sections/cta-burst"; import { RoleKicker } from "@/components/brand/role-kicker"; import { Texture } from "@/components/brand/texture"; @@ -88,6 +89,16 @@ export function renderComparisonContent(node: ReactNode, path: string): string { ])}\n\n`; } + if (type === ComparisonPricingTable) { + const { corner, columns, rows, note } = props as unknown as PricingTableProps; + const cell = (node: ReactNode) => render(node).trim().replace(/\s+/g, " "); + const grid = table( + [escapeText(corner), ...columns.map((column) => escapeText(column.label))], + rows.map((row) => [escapeText(row.label), ...row.cells.map(cell)]), + ); + return `\n\n${joinBlocks([grid, note ? render(note).trim() : ""])}\n\n`; + } + if (type === "table") { const rows = tableRows(props.children).map((row) => { if (!isValidElement(row)) return []; diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index ca66cb1d92..87ef759813 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -40,9 +40,9 @@ for (const entry of COMPARISONS) { `missing answer: ${faq.question}`, ); } - assert.ok(markdown.includes("## Pricing")); - assert.ok(markdown.includes("## What teams on Prisma say")); - assert.ok(markdown.includes("## Move from")); + assert.match(markdown, /\n## (Pricing|How much do .+ cost\?)\n/); + assert.match(markdown, /\n## (What teams on Prisma say|What do teams say about .+\?)\n/); + assert.match(markdown, /\n## (Move from|How do I switch from) /); }); test(`${entry.slug} article schema agrees with the page metadata and directory`, () => { @@ -174,9 +174,8 @@ test("kysely Markdown keeps yes and no marks out of the table, and anchored head hosted.startsWith("| Hosted database | None. Kysely is a library. | [Prisma Postgres]"), ); assert.doesNotMatch(hosted, /\b(Yes|No)\b/); - assert.ok(markdown.includes("\n## When should I choose Prisma ORM or Kysely\n")); + assert.ok(markdown.includes("\n## When should I choose Prisma ORM or Kysely?\n")); assert.ok(markdown.includes("\n### One contract for queries and migrations\n")); - assert.ok(markdown.includes("\n### Prisma ORM and Prisma Postgres\n")); assert.doesNotMatch(markdown, /Copy link|Copied/); }); @@ -184,7 +183,7 @@ test("kysely Markdown carries the database matrix and the task table as Markdown const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); - assert.ok(markdown.includes("\n## Database support\n")); + assert.ok(markdown.includes("\n## Which databases do Prisma ORM and Kysely support?\n")); assert.ok(markdown.includes("| Database | Kysely | Prisma ORM 8 | Prisma ORM 7 |")); for (const row of kysely.DATABASE_MATRIX) { const cells = [row.kysely, row.orm8, row.orm7].map((status) => status ?? "Not supported"); @@ -195,7 +194,7 @@ test("kysely Markdown carries the database matrix and the task table as Markdown } assert.ok(markdown.includes("](/compare/kysely#databases)"), "Databases row links to the matrix"); - assert.ok(markdown.includes("\n## Same job, different tool\n")); + assert.ok(markdown.includes("\n## How does each task look in Kysely and Prisma ORM?\n")); assert.ok(markdown.includes("| Task | Kysely | Prisma ORM |")); const plain = plainMarkdown(markdown); for (const row of kysely.SAME_JOB) { @@ -214,8 +213,8 @@ test("kysely Markdown renders the cards as headings and the fit columns as headi assert.ok(markdown.includes(`\n### ${card.title}\n`), `missing card heading: ${card.title}`); } for (const [heading, items] of [ - ["Where Kysely works well", kysely.KYSELY_FITS], - ["Where Prisma ORM fits better", kysely.PRISMA_FITS], + ["When is Kysely the better fit?", kysely.KYSELY_FITS], + ["When is Prisma ORM the better fit?", kysely.PRISMA_FITS], ] as const) { const section = markdown.split(`\n## ${heading}\n`)[1]?.split("\n## ")[0]; assert.ok(section, `missing column heading: ${heading}`); @@ -227,3 +226,24 @@ test("kysely Markdown renders the cards as headings and the fit columns as headi } } }); + +test("kysely Markdown carries pricing as a table followed by the free tier note", () => { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const section = markdown + .split("\n## How much do Prisma ORM and Kysely cost?\n")[1] + ?.split("\n## ")[0]; + assert.ok(section, "missing pricing section"); + assert.ok(section.includes("| Cost | Kysely | Prisma ORM |")); + for (const row of kysely.PRICING) { + const line = section.split("\n").find((text) => text.startsWith(`| ${row.label} |`)); + assert.ok(line, `missing pricing row: ${row.label}`); + assert.ok(plainMarkdown(line).includes(stripFaqAnswerMarkup(row.prisma))); + } + assert.match( + section.trimEnd(), + /\|\n\nPrisma Postgres has a Free plan: \$0 a month for 200,000 operations, 2 GB of storage, and 50 databases\. The \[pricing page\]\(\/pricing\) lists the paid plans\.$/, + ); + assert.doesNotMatch(markdown, /pricing page, October 2026/); +}); From 3d8ac0caa4dc6d9dd6a9b03f8cd52874169aefee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 10:40:13 +0000 Subject: [PATCH 18/52] Kysely compare: Impeccable audit, ORM CTAs, stacked tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the kysely audit on the shared compare article: focus rings, skip link, stacked phone tables, ORM quickstart CTAs, and a shorter choose section. ORM pages keep "Trusted by 500K+ developers globally" in the close — that figure is Prisma ORM usage, not unsourced marketing. Add a representative SQL-sent tab, Kysely-to-contract switch examples, Markdown freshness and alternate links, and /compare/kysely.json facts. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 32 + apps/site/src/app/compare/anchor-heading.tsx | 29 +- apps/site/src/app/compare/article-toc.tsx | 6 +- apps/site/src/app/compare/article.tsx | 90 +-- apps/site/src/app/compare/compare-table.tsx | 64 ++ .../site/src/app/compare/kysely.json/route.ts | 11 + apps/site/src/app/compare/kysely/_content.ts | 230 +++++-- .../site/src/app/compare/kysely/_sections.tsx | 565 ++++++------------ apps/site/src/app/compare/kysely/facts.ts | 106 ++++ .../site/src/app/compare/kysely/highlight.tsx | 61 +- apps/site/src/app/compare/kysely/page.tsx | 20 +- .../src/app/compare/kysely/quote-carousel.tsx | 60 -- .../src/app/compare/kysely/same-query.tsx | 38 ++ .../src/app/compare/mongoose/_sections.tsx | 4 +- apps/site/src/app/compare/pricing-table.tsx | 22 +- apps/site/src/app/compare/table-styles.ts | 23 +- apps/site/src/app/globals.css | 85 ++- apps/site/src/app/layout.tsx | 7 +- .../components/answer-pages/answer-page.tsx | 2 +- apps/site/src/components/ui/accordion.tsx | 6 +- apps/site/src/lib/comparison-metadata.ts | 18 +- apps/site/src/lib/markdown-pages.ts | 15 +- .../src/lib/markdown/comparison-content.ts | 12 + .../src/lib/markdown/comparisons.test.tsx | 151 ++++- apps/site/src/lib/markdown/comparisons.tsx | 4 + 25 files changed, 1074 insertions(+), 587 deletions(-) create mode 100644 apps/site/src/app/compare/compare-table.tsx create mode 100644 apps/site/src/app/compare/kysely.json/route.ts create mode 100644 apps/site/src/app/compare/kysely/facts.ts delete mode 100644 apps/site/src/app/compare/kysely/quote-carousel.tsx create mode 100644 apps/site/src/app/compare/kysely/same-query.tsx diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index 02a9baff2c..2228e80661 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -11,3 +11,35 @@ 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). The 500K+ line is how + * many people use Prisma ORM; it stays. The buttons go to the ORM quickstart + * and Prisma Postgres rather than a generic console pitch. + */ +export const ORM_COMPARISON_CTA = { + headline: "Add Prisma ORM to an existing project", + headlineMaxWidth: "max-w-[28ch]", + body: "Free to start, no credit card required.", + bodyMaxWidth: "max-w-[44ch]", + checks: COMPARISON_CTA.checks, + 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; + +export const ORM_HERO_CTAS = { + primary: { + label: "Get started with Prisma ORM", + href: "/docs/prisma-orm/quickstart/postgresql", + }, + secondary: { + label: "Get started with Prisma Postgres", + href: "https://console.prisma.io", + }, +} as const; diff --git a/apps/site/src/app/compare/anchor-heading.tsx b/apps/site/src/app/compare/anchor-heading.tsx index 67ec9e6235..29b13de983 100644 --- a/apps/site/src/app/compare/anchor-heading.tsx +++ b/apps/site/src/app/compare/anchor-heading.tsx @@ -3,9 +3,18 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Check, Link as LinkIcon } from "lucide-react"; +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 in the left gutter, following the - * copy-link pattern in Eclipse's accordion. The button is absolutely positioned + * 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. @@ -27,6 +36,7 @@ export function AnchorHeading({ const end = placement === "end"; const [copied, setCopied] = useState(false); const timer = useRef>(undefined); + const label = headingText(children).trim(); useEffect(() => () => clearTimeout(timer.current), []); @@ -45,19 +55,18 @@ export function AnchorHeading({ } return ( - - {children} +
          + + {children} + - +
          ); } diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index e8ff104470..2fb95980ad 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -97,14 +97,14 @@ function TocList({ onChoose: (id: string) => void; }) { return ( -
            +
              {items.map((item) => (
            • onChoose(item.id)} - className="-ml-px block border-l border-transparent py-1.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground/70 aria-[current=location]:text-foreground" + className="-ml-0.5 block border-l-2 border-transparent py-2.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" > {item.label} @@ -134,7 +134,7 @@ export function ArticleTocDisclosure({ items }: { items: readonly TocItem[] }) { const details = useRef(null); return (
              - + On this page = { { id: "same-query", label: "Same query" }, { id: "feature-table", label: "Features" }, { id: "databases", label: "Databases" }, - { id: "where-kysely", label: "Where each fits", also: ["where-prisma"] }, { id: "apart", label: "Why Prisma ORM" }, { id: "pricing", label: "Pricing" }, - { id: "wrong-choice", label: "Trade-offs" }, - { id: "same-job", label: "Switching", also: ["migrate", "migrate-back"] }, + { 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" }, @@ -53,43 +55,47 @@ const TOC: Record = { ], }; -/** Buttons under the introduction, matching the comparison pages' opening actions. */ -export function comparisonHeroCtas() { +/** 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 : COMPARISON_CTA.primaryCta; + const secondary = kind === "orm" ? ORM_HERO_CTAS.secondary : COMPARISON_CTA.secondaryCta; return (

              - - Get started free + + {primary.label} {" "} - - See pricing + + {secondary.label}

              ); } -/** Closing action from the comparison pages: the shared checks, console, and contact. */ -export function comparisonClosingCta() { +/** 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 (
              -

              - {COMPARISON_CTA.headline} +

              + {cta.headline}

              -

              {COMPARISON_CTA.body}

              -
                - {COMPARISON_CTA.checks.map((item) => ( +

                {cta.body}

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

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

              @@ -117,31 +123,36 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child return (
              -
              - - {intro} +
              +
              + + {intro} +
              -
              +
              -
              +
              {body}
              {closing} + {footnote} - {footnote}
              -
              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 0000000000..9e4bce04e7 --- /dev/null +++ b/apps/site/src/app/compare/compare-table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { SCROLL_HINT, STICKY, TABLE_FRAME, TABLE_STACK, TD, TH_COL, TH_ROW } from "./table-styles"; + +export type CompareTableRow = { + 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. */ + corner: string; + columns: readonly string[]; + rows: readonly CompareTableRow[]; + minWidth: string; +}; + +/** + * Feature, database, and task 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 columnWidth = `${78 / columns.length}%`; + return ( +
              + + + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.cells.map((cell, index) => ( + + ))} + + ))} + +
              + {corner} + + {column} +
              + {row.label} + + {cell} +
              +
              + ); +} 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 0000000000..4c302f0f34 --- /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 7945716f50..d216f56ad4 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -1,9 +1,9 @@ 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"; @@ -14,13 +14,14 @@ export const KYSELY_AUTHOR = { image: "/blog/authors/martin-janse-van-rensburg.png", } as const; -export const KYSELY_UPDATED = { iso: "2026-10-17", label: "October 2026" } 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 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.", + "You want related records as part of the model API, as in `db.orm.public.User.include`.", ]; export const CHOOSE_KYSELY = [ @@ -53,14 +54,53 @@ const user = await 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; - /** Only for rows whose cells answer a plain yes or no. */ - marks?: { kysely: "yes" | "no"; prisma: "yes" | "no" }; + sources: { kysely: string; prisma: string }; }; /** Columns run Feature, Kysely, Prisma ORM: alphabetical by product. */ @@ -69,6 +109,10 @@ 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", @@ -76,19 +120,31 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "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.", + 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.", 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.", 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", @@ -96,6 +152,10 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "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.", + sources: { + kysely: "https://kysely.dev/docs/migrations", + prisma: "https://www.prisma.io/docs/cli/migration-show", + }, }, { feature: "Databases", @@ -103,13 +163,20 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ "Built-in dialects for PostgreSQL, MySQL, Microsoft SQL Server, SQLite, and PGlite. See [database support](#databases).", prisma: "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.", - marks: { kysely: "no", prisma: "yes" }, 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", + }, }, ]; @@ -135,6 +202,12 @@ export const FULL_SUPPORT: readonly DatabaseStatus[] = [ "Supported", ]; +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; @@ -151,16 +224,40 @@ export const DATABASE_MATRIX: readonly { { database: "PGlite", kysely: "Built-in", orm8: null, orm7: null }, ]; -/** One row per job; every Kysely cell names the API or tool its docs give for it. */ /** What each side costs. Figures checked against the pricing page and each license. */ -export const PRICING: readonly { label: string; kysely: string; prisma: string }[] = [ - { label: "Library license and cost", kysely: "Free, MIT", prisma: "Free, Apache 2.0" }, - { label: "Paid plans", kysely: "None", prisma: "None for the ORM" }, +export const PRICING: readonly { + label: string; + kysely: string; + prisma: string; + sources: { kysely: string; prisma: string }; +}[] = [ + { + 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", + }, + }, + { + 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", + }, + }, { 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", + }, }, ]; @@ -173,69 +270,50 @@ export const SAME_JOB: readonly { task: string; kysely: string; prisma: string } { task: "Generate types", kysely: - "Nothing to generate if you write the interface by hand. Community generators such as kysely-codegen write it for you.", + "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: "`contract infer` inspects a live database and writes a starter `contract.prisma`.", + "`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.", + "`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 SQL", + 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: - "`migration show` prints a migration's operations and statement preview, without connecting to the database.", + "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`.", + "`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 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.", -]; - -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 APART = [ - { - id: "one-contract", - 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.", - }, { 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: "contract-from-tables", - 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.", - }, { 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.", }, ]; @@ -266,3 +344,65 @@ export const FAQ: readonly { question: string; answer: string }[] = [ "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.", }, ]; + +/** 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 6212fcc4b4..f6e2295df8 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,6 +1,5 @@ import Link from "next/link"; -import type { ReactNode } from "react"; -import { Check, Info, Minus } from "lucide-react"; +import { Info } from "lucide-react"; import { CodeBlock, CodeBlockTab, @@ -14,24 +13,14 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; -import { COMPARISON_CTA } from "../_content"; import { AnchorHeading } from "../anchor-heading"; -import { CompareArticle } from "../article"; +import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { CompareTable } from "../compare-table"; import { ComparisonPricingTable } from "../pricing-table"; -import { - QUIET_LINK, - QUIET_NOTE, - SCROLL_HINT, - STICKY, - TABLE_FRAME, - TD, - TH_COL, - TH_ROW, -} from "../table-styles"; -import { highlight } from "./highlight"; -import { QuoteCarousel } from "./quote-carousel"; +import { QUIET_LINK, QUIET_NOTE } from "../table-styles"; +import { highlight, lineGroupMap } from "./highlight"; +import { SameQueryHighlight } from "./same-query"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -41,12 +30,14 @@ import { COMPARE_ROWS, FAQ, KYSELY_AUTHOR, - KYSELY_FITS, KYSELY_TITLE, KYSELY_UPDATED, PRICING, - PRISMA_FITS, SAME_QUERY, + SAME_QUERY_LINE_GROUPS, + SWITCH_COMMANDS, + SWITCH_KYSELY_TYPES, + SWITCH_PRISMA_CONTRACT, WRONG_CHOICE, DATABASE_MATRIX, type DatabaseStatus, @@ -55,7 +46,7 @@ import { } 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. @@ -64,35 +55,19 @@ const INLINE = * `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.5 [&_code]:py-0.5 [&_code]:text-[0.875em] [&_code]:font-medium [&_code]:text-primary [&_code]:before:content-none [&_code]:after:content-none [&_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"; + "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"; - -/** The two "where it fits" columns share the choose table's frame and header strip. */ -const FIT_FRAME = - "grid overflow-hidden rounded-xl border border-black/[0.08] md:grid-cols-2 md:divide-x divide-black/[0.08] max-md:divide-y"; -const FIT_COLUMN = "flex flex-col"; -const FIT_HEADING = - "!font-sans border-b border-black/[0.06] bg-neutral-50 px-4 py-3 !text-sm !leading-6 font-semibold !tracking-normal"; -const FIT_BODY = - "flex flex-col gap-4 px-4 py-4 [&_:is(p,li)]:!text-[0.9375rem] [&_:is(p,li)]:!leading-6"; -const CARD = "flex flex-col gap-2 rounded-xl border border-black/[0.08] p-5"; +const CHOOSE_LABEL = "font-heading text-lg leading-snug font-medium"; /** - * Full support reads as body text; preview and ORM 7 labels are muted; a dash - * is no support. Called as a function so the Markdown walker sees the elements. + * 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 ( - <> - - — - - Not supported - - ); + return No; } return ( @@ -101,27 +76,6 @@ function databaseStatus(status: DatabaseStatus) { ); } -/** - * A quiet yes or no beside a cell's own wording. The icon is cap height and - * centred on the first line; its label is an attribute, so Markdown keeps the text alone. - */ -function MarkedCell({ mark, children }: { mark?: "yes" | "no"; children: ReactNode }) { - if (!mark) return children; - const Icon = mark === "yes" ? Check : Minus; - return ( - - - - - {children} - - ); -} - /** * The site does not build the eclipse tokens these components use * (`rounded-square`, `border-stroke-neutral`, `type-text-sm-strong`, the active @@ -130,9 +84,9 @@ function MarkedCell({ mark, children }: { mark?: "yes" | "no"; children: ReactNo const CODE_TABS = "my-0 rounded-[0.625rem] border-black/[0.08] bg-white"; const CODE_TAB_LIST = "flex flex-row overflow-x-auto px-2"; const CODE_TAB = - "relative inline-flex items-center px-2 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-[state=active]:text-primary data-[state=active]:[&>div]:bg-primary"; + "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 = - "my-0 rounded-none border-black/[0.08] bg-white [&>div]:py-3.5 [&_button]:text-muted-foreground"; + "my-0 rounded-none border-black/[0.08] bg-white [&>div]:py-3.5 [&_.line]:pr-12 [&_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", @@ -187,11 +141,11 @@ const SOURCES = [ function SourceLink({ href, children }: { href: string; children: string }) { return href.startsWith("/") ? ( - + {children} ) : ( - + {children} ); @@ -199,6 +153,10 @@ function SourceLink({ href, children }: { href: string; children: string }) { /** Build the shared content tree for the HTML page and its Markdown rendition. */ export function getKyselyPageContent() { + 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 [

              @@ -217,80 +175,73 @@ export function getKyselyPageContent() { starts from SQL: you write each query as a fluent TypeScript builder over table types you keep yourself.

              -
              - - Get started free - {" "} - - See pricing - -
              -

              , -
              -
              -

              - - - - By{" "} - - {KYSELY_AUTHOR.name} - - {" "} - ·{" "} - - Updated - + {comparisonHeroCtas("orm")} +

              + + + + By{" "} + + {KYSELY_AUTHOR.name} + + {" "} + ·{" "} + + Updated + +

              +
              + +
              , +
              - When should I choose Prisma ORM or Kysely? + When should I choose Kysely or Prisma ORM? -
              - - - - - - - - - {CHOOSE_KYSELY.map((kysely, index) => ( - - - - +

              + A contract is the contract.prisma file that describes your models. A dialect + is Kysely's adapter for one database. db sign records that your + database already matches the contract. Reasons are independent lists, in alphabetical + order by product. +

              +
              +
              +

              Kysely

              +
                + {CHOOSE_KYSELY.map((item) => ( +
              • {renderFaqAnswer(item)}
              • ))} -
              -
              - Kysely - - Prisma ORM -
              {renderFaqAnswer(kysely)}{renderFaqAnswer(CHOOSE_PRISMA[index])}
              +
            +
    +
    +

    Prisma ORM

    +
      + {CHOOSE_PRISMA.map((item) => ( +
    • {renderFaqAnswer(item)}
    • + ))} +
    +
    @@ -301,27 +252,38 @@ export function getKyselyPageContent() {

    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 - - - Prisma ORM - - - {(["kysely", "prisma"] as const).map((tool) => ( - - {highlight(SAME_QUERY[tool])} + + + + + Kysely + + + Prisma ORM + + + SQL sent + + + + + {highlight(SAME_QUERY.kysely, "typescript", kyselyGroups)} + - ))} - + + + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} + + + + + {highlight(SAME_QUERY.sql, "sql", sqlGroups)} + + + +
    @@ -332,47 +294,16 @@ export function getKyselyPageContent() { 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.

    -
    - - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
    - Feature - - Kysely - - Prisma ORM -
    - {row.feature} - - {renderFaqAnswer(row.kysely)} - - {renderFaqAnswer(row.prisma)} -
    -
    + ({ + label: row.feature, + cells: [renderFaqAnswer(row.kysely), renderFaqAnswer(row.prisma)], + }))} + />
    @@ -381,50 +312,22 @@ export function getKyselyPageContent() {

    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. A dash means no - support. + available release, and it supports every database here except PGlite. No means no support.

    -
    - - - - - - - - - - - {DATABASE_MATRIX.map((row) => ( - - - - - - - ))} - -
    - Database - - Kysely - - Prisma ORM 8 - - Prisma ORM 7 -
    - {row.database} - {databaseStatus(row.kysely)}{databaseStatus(row.orm8)}{databaseStatus(row.orm7)}
    -
    + ({ + label: row.database, + cells: [ + databaseStatus(row.kysely), + databaseStatus(row.orm8), + databaseStatus(row.orm7), + ], + }))} + />

    MariaDB on Kysely is the community{" "} @@ -446,65 +349,17 @@ export function getKyselyPageContent() {

    -
    -
    - - When is Kysely the better fit? - -
    -

    - The Kysely{" "} - - 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) => ( -
    • {renderFaqAnswer(item)}
    • - ))} -
    -
    -
    -
    - - When is Prisma ORM the better fit? - -
    -

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

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

    {renderFaqAnswer(item.body)}

    +

    {renderFaqAnswer(item.body)}

    ))}
    @@ -557,64 +412,53 @@ export function getKyselyPageContent() { What each step of the usual workflow looks like on each side, for a team moving in either direction.

    -
    - - - - - - - - - - {SAME_JOB.map((row) => ( - - - - - - ))} - -
    - Task - - Kysely - - Prisma ORM -
    - {row.task} - {renderFaqAnswer(row.kysely)}{renderFaqAnswer(row.prisma)}
    -
    + ({ + label: row.task, + cells: [renderFaqAnswer(row.kysely), renderFaqAnswer(row.prisma)], + }))} + />
    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. - 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. Later changes go through{" "} @@ -656,7 +500,7 @@ export function getKyselyPageContent() { What do teams say about Prisma ORM?

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

      - +
      {PROOF.map((item) => (
      -
      - "{item.quote}" +
      + “{item.quote}”
      {item.name}, {item.role},{" "} @@ -675,7 +519,7 @@ export function getKyselyPageContent() {
      ))} - +
    @@ -688,7 +532,7 @@ export function getKyselyPageContent() { {faq.question} - + {renderFaqAnswer(faq.answer)} @@ -696,47 +540,24 @@ export function getKyselyPageContent() {
    , -
    -

    - {COMPARISON_CTA.headline} -

    -

    {COMPARISON_CTA.body}

    -
      - {COMPARISON_CTA.checks.map((item) => ( -
    • {item.label}
    • - ))} -
    -
    - - {COMPARISON_CTA.primaryCta.label} - {" "} - - {COMPARISON_CTA.secondaryCta.label} - -
    -
    , + 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} 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 0000000000..0e595afab5 --- /dev/null +++ b/apps/site/src/app/compare/kysely/facts.ts @@ -0,0 +1,106 @@ +import { + COMPARE_ROWS, + DATABASE_MATRIX, + DATABASE_SOURCES, + KYSELY_PATH, + KYSELY_PUBLISHED, + KYSELY_UPDATED, + PRICING, + TOOLS, +} from "./_content"; + +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() { + return { + datePublished: KYSELY_PUBLISHED.iso, + dateModified: KYSELY_UPDATED.iso, + about: getKyselyAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/kysely/highlight.tsx b/apps/site/src/app/compare/kysely/highlight.tsx index 45e70dc773..fcb22539a5 100644 --- a/apps/site/src/app/compare/kysely/highlight.tsx +++ b/apps/site/src/app/compare/kysely/highlight.tsx @@ -1,18 +1,30 @@ import type { CSSProperties } from "react"; import { createHighlighterCoreSync } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; -import githubDark from "shiki/themes/github-dark.mjs"; -import githubLight from "shiki/themes/github-light.mjs"; +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", +}; + /** - * The docs' Shiki themes with `defaultColor: false`, so tokens carry - * `--shiki-light` / `--shiki-dark` and `globals.css` picks the active one. - * Synchronous, because the same content tree also renders to Markdown. + * 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: [githubLight, githubDark], - langs: [typescript], + themes: [githubLightHighContrast, githubDarkHighContrast], + langs: [typescript, sql, prisma, bash], engine: createJavaScriptRegexEngine(), }); @@ -38,23 +50,34 @@ const BLOCK_CODE: CSSProperties = { color: "inherit", }; +const DATA_LANG: Record = { + typescript: "ts", + sql: "sql", + prisma: "prisma", + bash: "bash", +}; + /** A highlighted `pre` with the docs' code block layout: one flex row per line. */ -export function highlight(code: string) { +export function highlight( + code: string, + lang: HighlightLang = "typescript", + lineGroups: Readonly> = {}, +) { const { tokens } = highlighter.codeToTokens(code, { - lang: "typescript", - themes: { light: "github-light", dark: "github-dark" }, + 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) => (
        @@ -68,3 +91,17 @@ export function highlight(code: string) {
             
        ); } + +/** 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/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 1c6862405a..6957f21e1a 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -3,22 +3,33 @@ import { JsonLd } from "@prisma-docs/ui/components/json-ld"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; import { getBaseUrl } from "@/lib/url"; -import { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE, KYSELY_UPDATED } from "./_content"; +import { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE } from "./_content"; +import { getKyselyArticleFields } from "./facts"; import { KyselyPage } from "./_sections"; -/** The docs' code face, loaded here rather than in `highlight.tsx` because tests import that file outside Next. */ +/** The docs' code face. Preload is off so the CSS URL and the preload URL cannot disagree. */ const monaSansMono = localFont({ src: "../../../../../../packages/eclipse/src/static/fonts/MonaSansMonoVF[wght].woff2", variable: "--font-code", display: "swap", weight: "200 900", + preload: false, }); export const metadata = getComparisonMetadata("kysely"); const faqStructuredData = createFaqStructuredData(KYSELY_PATH, [...FAQ], KYSELY_TITLE); -/** The shared comparison graph, with this page's author and update date on its Article. */ +function ogImageUrl() { + const images = getComparisonMetadata("kysely").openGraph?.images; + const first = Array.isArray(images) ? images[0] : images; + if (!first) return undefined; + if (typeof first === "string") return first; + if ("url" in first) return typeof first.url === "string" ? first.url : first.url.toString(); + return undefined; +} + +/** 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"]; @@ -27,7 +38,8 @@ function articleStructuredData() { "@graph": [ { ...article, - dateModified: KYSELY_UPDATED.iso, + ...getKyselyArticleFields(), + image: ogImageUrl(), author: { "@type": "Person", name: KYSELY_AUTHOR.name, diff --git a/apps/site/src/app/compare/kysely/quote-carousel.tsx b/apps/site/src/app/compare/kysely/quote-carousel.tsx deleted file mode 100644 index fa7033f656..0000000000 --- a/apps/site/src/app/compare/kysely/quote-carousel.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Children, useState, type ReactNode } from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; - -const CONTROL = - "inline-flex size-9 items-center justify-center rounded-full border border-black/[0.08] text-foreground outline-none transition-colors hover:bg-black/[0.04] focus-visible:ring-[3px] focus-visible:ring-ring/50"; - -/** One quote at a time. The figures stay in the document so the text is still readable and copyable. */ -export function QuoteCarousel({ children }: { children: ReactNode }) { - const slides = Children.toArray(children); - const count = slides.length; - const [index, setIndex] = useState(0); - const go = (direction: -1 | 1) => setIndex((current) => (current + direction + count) % count); - - return ( -
        -
        -
        - {slides.map((slide, slideIndex) => ( -
        - {slide} -
        - ))} -
        -
        -
        - - {index + 1} / {count} - -
        - - -
        -
        -
        - ); -} 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 0000000000..51b932d51a --- /dev/null +++ b/apps/site/src/app/compare/kysely/same-query.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useCallback, type MouseEvent, type ReactNode } from "react"; + +/** + * Hovering or focusing a mapped line in any same-query tab highlights its + * counterparts (force-mounted, so the group is marked even in hidden tabs). + */ +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("bg-prism-cyan-50", 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("bg-prism-cyan-50"); + } + }, []); + + return ( +
        mark(event)} + > + {children} +
        + ); +} diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index 0fd204c148..baf3bccf8f 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -73,7 +73,7 @@ export function getMongoosePageContent() { See which fits

        - {comparisonHeroCtas()} + {comparisonHeroCtas("orm")}
    ,
    @@ -430,7 +430,7 @@ export function getMongoosePageContent() {
    , - comparisonClosingCta(), + comparisonClosingCta("orm"), ]; } diff --git a/apps/site/src/app/compare/pricing-table.tsx b/apps/site/src/app/compare/pricing-table.tsx index be550283ba..4d250133ab 100644 --- a/apps/site/src/app/compare/pricing-table.tsx +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; -import { QUIET_NOTE, SCROLL_HINT, STICKY, TABLE_FRAME, TD, TH_COL, TH_ROW } from "./table-styles"; +import { + QUIET_NOTE, + SCROLL_HINT, + STICKY, + TABLE_FRAME, + TABLE_STACK, + TD, + TH_COL, + TH_ROW, +} from "./table-styles"; export type PricingColumn = { label: string; @@ -26,7 +35,8 @@ export type PricingTableProps = { /** * 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. The Markdown + * 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({ @@ -40,7 +50,7 @@ export function ComparisonPricingTable({ return ( <>
    {row.cells.map((cell, index) => ( -
    + {cell}
    +
    {rows.map((row) => ( - + diff --git a/apps/site/src/app/compare/copied.ts b/apps/site/src/app/compare/copied.ts new file mode 100644 index 0000000000..e60c2e7274 --- /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/_content.ts b/apps/site/src/app/compare/kysely/_content.ts index d216f56ad4..e50690fe52 100644 --- a/apps/site/src/app/compare/kysely/_content.ts +++ b/apps/site/src/app/compare/kysely/_content.ts @@ -20,15 +20,15 @@ export const KYSELY_UPDATED = { iso: "2026-10-18", label: "October 2026" } as co export const CHOOSE_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.", + "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 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. */ @@ -292,7 +292,8 @@ export const SAME_JOB: readonly { task: string; kysely: string; prisma: string } }, { task: "Preview migration SQL", - kysely: "You read the SQL you wrote in the `up` function. There is no separate preview command.", + 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.", }, diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 5ae4b8f6ea..ce92e57d4c 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { Info } from "lucide-react"; import { CodeBlock, CodeBlockTab, @@ -22,6 +21,7 @@ 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 { PageAiActions } from "../page-ai-actions"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -82,14 +82,12 @@ function databaseStatus(status: DatabaseStatus) { * (`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 rounded-[0.625rem] border-black/[0.08] bg-white"; +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-black/[0.08] bg-white [&>div]:py-3.5"; -const CODE_BLOCK_WITH_COPY = - `${CODE_BLOCK} [&>[role=region]]:mr-11 [&_button]:relative [&_button]:text-muted-foreground [&_button]:after:absolute [&_button]:after:-inset-2.5 [&_button]:after:content-['']`; +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", @@ -203,31 +201,20 @@ export function getKyselyPageContent() {

    - + +

    + Prisma makes Prisma ORM. Kysely figures come from the pages listed under{" "} + + Sources + + , checked {CHECKED}. +

    ,
    When should I choose Kysely or Prisma ORM? -

    - A contract is the contract.prisma file that describes your models. A dialect - is Kysely's adapter for one database. db sign records that your - database already matches the contract. Reasons are independent lists, in alphabetical - order by product. -

    Kysely

    @@ -289,6 +276,23 @@ export function getKyselyPageContent() { +

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

    @@ -327,11 +331,7 @@ export function getKyselyPageContent() { minWidth="34rem" rows={DATABASE_MATRIX.map((row) => ({ label: row.database, - cells: [ - databaseStatus(row.kysely), - databaseStatus(row.orm8), - databaseStatus(row.orm7), - ], + cells: [databaseStatus(row.kysely), databaseStatus(row.orm8), databaseStatus(row.orm7)], }))} />

    @@ -361,8 +361,12 @@ export function getKyselyPageContent() {

    {APART.map((item) => ( -
    - +
    + {item.title}

    {renderFaqAnswer(item.body)}

    @@ -439,12 +443,15 @@ export function getKyselyPageContent() { 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. + 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.

    -

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

    @@ -537,7 +544,7 @@ export function getKyselyPageContent() { Frequently asked questions - + {FAQ.map((faq, index) => ( diff --git a/apps/site/src/app/compare/kysely/highlight.tsx b/apps/site/src/app/compare/kysely/highlight.tsx index 224f25cb6e..bcbc94faf1 100644 --- a/apps/site/src/app/compare/kysely/highlight.tsx +++ b/apps/site/src/app/compare/kysely/highlight.tsx @@ -77,7 +77,17 @@ export function highlight( {tokens.flatMap((line, index) => [ index > 0 ? "\n" : null, - + {line.length === 0 ? " " : line.map((token) => ( diff --git a/apps/site/src/app/compare/kysely/same-query.tsx b/apps/site/src/app/compare/kysely/same-query.tsx index 90ebb125cd..576c7b1ddc 100644 --- a/apps/site/src/app/compare/kysely/same-query.tsx +++ b/apps/site/src/app/compare/kysely/same-query.tsx @@ -3,8 +3,9 @@ import { useCallback, type MouseEvent, type ReactNode } from "react"; /** - * Hovering or focusing a mapped line highlights its counterparts in the - * open tab. Inactive tabs are not mounted, so the mapping is in-tab only. + * 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 }) => { @@ -14,14 +15,16 @@ export function SameQueryHighlight({ children }: { children: ReactNode }) { ?.closest("[data-group]") ?.getAttribute("data-group"); for (const line of root.querySelectorAll("[data-group]")) { - line.classList.toggle("bg-prism-cyan-50", Boolean(group) && line.dataset.group === 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("bg-prism-cyan-50"); + line.classList.remove("ring-1", "ring-inset", "ring-foreground/25"); } }, []); 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 0000000000..83c765c1b7 --- /dev/null +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronDown, Copy } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { COPIED_VISIBLE_MS } from "./copied"; + +const cache = new Map(); + +const ACTION = + "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; + +function promptFor(markdownUrl: string) { + return `Read ${markdownUrl}, I want to ask questions about it.`; +} + +function toolLinks(markdownUrl: string) { + const q = promptFor(markdownUrl); + return [ + { + name: "ChatGPT", + href: `https://chatgpt.com/?${new URLSearchParams({ hints: "search", q })}`, + }, + { + name: "Claude", + href: `https://claude.ai/new?${new URLSearchParams({ q })}`, + }, + { + name: "Gemini", + href: `https://gemini.google.com/app?${new URLSearchParams({ q })}`, + }, + { + name: "Perplexity", + href: `https://www.perplexity.ai/search/new?${new URLSearchParams({ q })}`, + }, + { + name: "Grok", + href: `https://grok.com/?${new URLSearchParams({ q })}`, + }, + ]; +} + +/** Compact Copy page + Open-in control. Matches the docs pattern, with the five public AIs. */ +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 = origin ? new URL(markdownPath, origin).toString() : markdownPath; + 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 ( +
    + + + + Open + + + + {tools.map((tool) => ( + + Open in {tool.name} + + ))} + + +
    + ); +} diff --git a/apps/site/src/app/compare/pricing-table.tsx b/apps/site/src/app/compare/pricing-table.tsx index 367058189d..12c63b8da7 100644 --- a/apps/site/src/app/compare/pricing-table.tsx +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -4,6 +4,7 @@ import { SCROLL_HINT, STICKY, TABLE_FRAME, + TABLE_ROW, TABLE_STACK, TD, TH_COL, @@ -81,7 +82,7 @@ export function ComparisonPricingTable({
    {rows.map((row) => ( - + diff --git a/apps/site/src/app/compare/tab-code-copy.tsx b/apps/site/src/app/compare/tab-code-copy.tsx index 1d076e872d..0165b3b6f7 100644 --- a/apps/site/src/app/compare/tab-code-copy.tsx +++ b/apps/site/src/app/compare/tab-code-copy.tsx @@ -2,6 +2,7 @@ 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; @@ -35,7 +36,7 @@ export function TabCodeCopy({ label }: { label: string }) { } setCopied(true); clearTimeout(timer.current); - timer.current = setTimeout(() => setCopied(false), 1600); + timer.current = setTimeout(() => setCopied(false), COPIED_VISIBLE_MS); } return ( @@ -50,6 +51,10 @@ export function TabCodeCopy({ label }: { label: string }) { ) : ( )} + {copied ? "Copied" : ""} diff --git a/apps/site/src/app/compare/table-styles.ts b/apps/site/src/app/compare/table-styles.ts index d5ef9e3dfd..2d0aab0a46 100644 --- a/apps/site/src/app/compare/table-styles.ts +++ b/apps/site/src/app/compare/table-styles.ts @@ -9,9 +9,10 @@ export const TABLE_FRAME = */ export const TABLE_STACK = "compare-stack"; -export const TH_COL = "bg-neutral-50 text-sm font-semibold leading-6 text-primary"; +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` @@ -25,7 +26,7 @@ export const SCROLL_HINT = { /** 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-black/[0.06]"; + "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 = diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index aac54f32a7..d7c0a2eb21 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -5,6 +5,7 @@ import { AnchorHeading } from "@/app/compare/anchor-heading"; import { CompareTable, type CompareTableProps } from "@/app/compare/compare-table"; import { ComparisonPricingTable, type PricingTableProps } from "@/app/compare/pricing-table"; import { CtaBurst } from "@/components/sections/cta-burst"; +import { PageAiActions } from "@/app/compare/page-ai-actions"; import { RoleKicker } from "@/components/brand/role-kicker"; import { Texture } from "@/components/brand/texture"; import { ctaList, heading, joinBlocks, table } from "./blocks"; @@ -41,7 +42,14 @@ export function renderComparisonContent(node: ReactNode, path: string): string { if (Array.isArray(value)) return value.map(render).join(""); if (!isValidElement(value)) return ""; const { type, props } = value; - if (props["aria-hidden"] === true || type === RoleKicker || type === Texture) return ""; + if ( + props["aria-hidden"] === true || + type === RoleKicker || + type === Texture || + type === PageAiActions + ) { + return ""; + } if (type === "pre") { const language = typeof props["data-language"] === "string" ? props["data-language"] : ""; return `\n\n\`\`\`${language}\n${codeText(props.children)}\n\`\`\`\n\n`; diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 85c0b22ea3..d5cb65d220 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -267,25 +267,32 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok( COMPARISON_CTA.checks.some((item) => item.label === "Trusted by 500K+ developers globally"), ); - assert.equal(ORM_COMPARISON_CTA.checks, COMPARISON_CTA.checks); - assert.equal( - ORM_COMPARISON_CTA.body, - "Prisma ORM is free and open source, and always will be.", + assert.ok( + ORM_COMPARISON_CTA.checks.some((item) => item.label === "Trusted by 500K+ developers globally"), ); + assert.ok(ORM_COMPARISON_CTA.checks.some((item) => item.label === "Add the library")); + assert.ok(ORM_COMPARISON_CTA.checks.some((item) => item.label === "Read the ORM docs")); + assert.equal(ORM_COMPARISON_CTA.body, "Prisma ORM is free and open source, and always will be."); assert.equal(COMPARISON_CTA.body, "Free to start, no credit card required."); const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); assert.ok(markdown.includes("Trusted by 500K+ developers globally")); assert.ok(markdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.ok(markdown.includes("Add the library")); + assert.ok(markdown.includes("Read the ORM docs")); assert.doesNotMatch(markdown, /Free to start, no credit card required/); + assert.doesNotMatch(markdown, /Create a database and start building in minutes/); assert.ok(markdown.includes("\n## Add Prisma ORM to an existing project\n")); - assert.ok(markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)")); + assert.ok( + markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)"), + ); assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); const mongoosePage = getMarkdownPage("/compare/mongoose"); assert.ok(mongoosePage); const mongooseMarkdown = renderMarkdownDocument(mongoosePage); assert.ok(mongooseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.ok(mongooseMarkdown.includes("Add the library")); assert.doesNotMatch(mongooseMarkdown, /Free to start, no credit card required/); const convexPage = getMarkdownPage("/compare/convex"); assert.ok(convexPage); @@ -301,7 +308,9 @@ test("kysely Markdown carries the switch types, contract, and four commands", () markdown.includes(`**Kysely types**\n\n\`\`\`ts\n${kysely.SWITCH_KYSELY_TYPES}\n\`\`\``), ); assert.ok( - markdown.includes(`**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``), + markdown.includes( + `**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``, + ), ); assert.ok(markdown.includes(`\`\`\`bash\n${kysely.SWITCH_COMMANDS}\n\`\`\``)); }); @@ -390,10 +399,14 @@ const compareDir = join(dirname(fileURLToPath(import.meta.url)), "../../app/comp test("comparison tables use the border token and only tab-stop when they scroll", () => { assert.match(TABLE_FRAME, /\bborder-border\b/); assert.doesNotMatch(TABLE_FRAME, /border-black\/\[0\.08\]/); + const styles = readFileSync(join(compareDir, "table-styles.ts"), "utf8"); + assert.match(styles, /TABLE_ROW = "border-t border-border"/); for (const file of ["compare-table.tsx", "pricing-table.tsx"]) { const source = readFileSync(join(compareDir, file), "utf8"); assert.doesNotMatch(source, /tabIndex=\{0\}/); assert.match(source, /TableScrollRegion/); + assert.match(source, /TABLE_ROW/); + assert.doesNotMatch(source, /border-black\/\[0\.06\]/); } }); @@ -411,13 +424,17 @@ test("kysely code tabs stay unmounted when closed, while Markdown still has ever markdown.includes(`**Kysely types**\n\n\`\`\`ts\n${kysely.SWITCH_KYSELY_TYPES}\n\`\`\``), ); assert.ok( - markdown.includes(`**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``), + markdown.includes( + `**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``, + ), ); }); test("kysely code lines pad past the Copy pill and preload the unbracketed mono face", () => { const highlight = readFileSync(join(compareDir, "kysely/highlight.tsx"), "utf8"); - assert.match(highlight, /className="line pl-4 pr-12"/); + assert.match(highlight, /line pl-4 pr-12/); + assert.match(highlight, /bg-prism-cyan-100/); + assert.match(highlight, /bg-prism-yellow-100/); const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); assert.match(sections, /not-fumadocs-codeblock/); assert.match(sections, / { + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const choose = markdown.slice( + markdown.indexOf("## When should I choose Kysely or Prisma ORM?"), + markdown.indexOf("## What does the same query look like in each?"), + ); + assert.match(choose, /### Kysely/); + assert.doesNotMatch(choose, /A contract is the/); + assert.match(choose, /a contract is the `contract\.prisma` file that describes your models/); + assert.match(choose, /dialect — Kysely's adapter for one database/); + assert.match(choose, /`db sign` records that the database already matches the contract/); + assert.match(markdown, /posts include/); + assert.match(markdown, /email filter/); + const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); + assert.match(sections, //); + const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); + assert.match(ai, /Open in \{tool\.name\}/); + assert.match(ai, /name: "ChatGPT"/); + assert.match(ai, /name: "Claude"/); + assert.match(ai, /name: "Gemini"/); + assert.match(ai, /name: "Perplexity"/); + assert.match(ai, /name: "Grok"/); + assert.match(ai, /claude\.ai\/new/); + assert.match(ai, /gemini\.google\.com\/app/); + assert.match(ai, /perplexity\.ai\/search\/new/); + assert.match(ai, /grok\.com/); }); From b333b2cff6649bc706de5402921e2c3c7ea3a169 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 15:38:25 +0000 Subject: [PATCH 26/52] Compare: Open with AI logos and a supporting create-db line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the mystery Open dropdown with a labelled row of five AI marks that prefill the choose prompt. Keep Copy page as its own control. On the ORM close, add “Need a database? Create one instantly” without changing the headline, always-free dek, or 500K line. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 12 ++ apps/site/src/app/compare/page-ai-actions.tsx | 172 ++++++++++++++---- .../src/lib/markdown/comparisons.test.tsx | 11 +- 3 files changed, 155 insertions(+), 40 deletions(-) diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 0e0d7f0210..c822d035a6 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -90,6 +90,18 @@ export function comparisonClosingCta(kind: "orm" | "postgres" = "postgres") {
  • {item.label}
  • ))} + {kind === "orm" ? ( +

    + Need a database?{" "} + + Create one instantly + + . +

    + ) : null}

    {cta.primaryCta.label} diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index 83c765c1b7..103ad6c537 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -1,46 +1,136 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Check, ChevronDown, Copy } from "lucide-react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +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 ACTION = +const COPY = "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; -function promptFor(markdownUrl: string) { - return `Read ${markdownUrl}, I want to ask questions about it.`; +const LOGO = + "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70"; + +/** Prompt every Open-with-AI logo prefills. `{PAGE_MD_URL}` is the page's `.md` alternate. */ +export function choosePrompt(pageMdUrl: string) { + return `Read this article and treat it as the source of truth: + +${pageMdUrl} + +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.`; } function toolLinks(markdownUrl: string) { - const q = promptFor(markdownUrl); + 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: , }, ]; } -/** Compact Copy page + Open-in control. Matches the docs pattern, with the five public AIs. */ +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 page plus a labelled row of AI logos. No dropdown. */ export function PageAiActions({ markdownPath }: { markdownPath: string }) { const [copied, setCopied] = useState(false); const [loading, setLoading] = useState(false); @@ -53,7 +143,7 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) { }, []); const tools = useMemo(() => { - const markdownUrl = origin ? new URL(markdownPath, origin).toString() : markdownPath; + const markdownUrl = new URL(markdownPath, origin || getBaseUrl()).toString(); return toolLinks(markdownUrl); }, [markdownPath, origin]); @@ -79,37 +169,41 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) { } return ( -

    - - - - Open - - - + +
    + +
    + Open with AI {tools.map((tool) => ( - - Open in {tool.name} - + + + + {tool.icon} + + + + {tool.tooltip} + + ))} - - -
    +
    +
    + ); } diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index d5cb65d220..3b6bdd9c92 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -281,6 +281,10 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok(markdown.includes("Prisma ORM is free and open source, and always will be.")); assert.ok(markdown.includes("Add the library")); assert.ok(markdown.includes("Read the ORM docs")); + assert.match( + markdown, + /Need a database\? \[Create one instantly\]\(https:\/\/console\.prisma\.io\)/, + ); assert.doesNotMatch(markdown, /Free to start, no credit card required/); assert.doesNotMatch(markdown, /Create a database and start building in minutes/); assert.ok(markdown.includes("\n## Add Prisma ORM to an existing project\n")); @@ -469,12 +473,17 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); assert.match(sections, //); const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); - assert.match(ai, /Open in \{tool\.name\}/); + assert.match(ai, /Open with AI/); + assert.doesNotMatch(ai, /Popover/); assert.match(ai, /name: "ChatGPT"/); assert.match(ai, /name: "Claude"/); assert.match(ai, /name: "Gemini"/); assert.match(ai, /name: "Perplexity"/); assert.match(ai, /name: "Grok"/); + assert.match(ai, /Opens ChatGPT with this comparison and a prompt to help you choose/); + assert.match(ai, /Read this article and treat it as the source of truth:/); + assert.match(ai, /I am choosing between Prisma ORM and Kysely for a TypeScript app/); + assert.doesNotMatch(ai, /I want to ask questions about it/); assert.match(ai, /claude\.ai\/new/); assert.match(ai, /gemini\.google\.com\/app/); assert.match(ai, /perplexity\.ai\/search\/new/); From 354cec90d9fb3843bd0c5cefb5b74c84e2a4c22b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 15:43:16 +0000 Subject: [PATCH 27/52] Compare: keep Open-with-AI logos on one row at 390 The label stacks above the five marks on small screens so Grok no longer wraps onto a line by itself. Co-authored-by: rensburg --- apps/site/src/app/compare/page-ai-actions.tsx | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index 103ad6c537..1002562a19 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -182,26 +182,28 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) { {copied ? "Page copied" : ""}
    -
    +
    Open with AI - {tools.map((tool) => ( - - - - {tool.icon} - - - - {tool.tooltip} - - - ))} +
    + {tools.map((tool) => ( + + + + {tool.icon} + + + + {tool.tooltip} + + + ))} +
    From 6f4d857993ac60002f4171a0107698e1f0fc6139 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 16:43:17 +0000 Subject: [PATCH 28/52] Compare: replace Copy page with an icon in the AI row The clipboard sits first, a hairline separates it from the five AI marks, and tooltips carry the names so copy is not read as another model. Co-authored-by: rensburg --- apps/site/src/app/compare/page-ai-actions.tsx | 85 ++++++++++--------- .../src/lib/markdown/comparisons.test.tsx | 4 +- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index 1002562a19..a10f6f6ad5 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -8,11 +8,10 @@ import { COPIED_VISIBLE_MS } from "./copied"; const cache = new Map(); -const COPY = - "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; +const COPY_LABEL = "Copy this page as Markdown"; -const LOGO = - "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70"; +const ICON = + "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; /** Prompt every Open-with-AI logo prefills. `{PAGE_MD_URL}` is the page's `.md` alternate. */ export function choosePrompt(pageMdUrl: string) { @@ -130,7 +129,7 @@ function GrokMark() { ); } -/** Copy page plus a labelled row of AI logos. No dropdown. */ +/** 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); @@ -170,41 +169,47 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) { return ( -
    - -
    - Open with AI -
    - {tools.map((tool) => ( - - - - {tool.icon} - - - - {tool.tooltip} - - - ))} -
    -
    +
    + + + + + {copied ? "Copied" : COPY_LABEL} + + + {tools.map((tool) => ( + + + + {tool.icon} + + + + {tool.tooltip} + + + ))}
    ); diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 3b6bdd9c92..e2a8b5d68c 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -473,7 +473,9 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); assert.match(sections, //); const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); - assert.match(ai, /Open with AI/); + assert.match(ai, /Copy this page as Markdown/); + assert.doesNotMatch(ai, />Open with AI Date: Sun, 18 Oct 2026 16:53:27 +0000 Subject: [PATCH 29/52] Compare: move copy and AI marks under the TOC On large screens the cluster sits in the sticky rail below On this page. On small screens it lives at the bottom of the TOC disclosure, so the hero stays title, dek, CTAs, and byline. Co-authored-by: rensburg --- apps/site/src/app/compare/article-toc.tsx | 27 +++++++++++++++++-- apps/site/src/app/compare/article.tsx | 8 ++++-- .../site/src/app/compare/kysely/_sections.tsx | 2 -- apps/site/src/app/compare/page-ai-actions.tsx | 16 ++++++----- .../src/lib/markdown/comparisons.test.tsx | 7 ++++- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index 8b6d45f186..85b2bfcc71 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -2,6 +2,7 @@ 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[] }; @@ -115,7 +116,13 @@ function TocList({ } /** The sticky right-hand list, matching the docs' table of contents. */ -export function ArticleToc({ items }: { items: readonly TocItem[] }) { +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 }: { items: readonly TocItem[] }) { +export function ArticleTocDisclosure({ + items, + markdownPath, +}: { + items: readonly TocItem[]; + markdownPath?: string; +}) { const [active, choose] = useActiveItem(items); const details = useRef(null); return ( @@ -150,6 +168,11 @@ export function ArticleTocDisclosure({ items }: { items: readonly TocItem[] }) { if (details.current) details.current.open = false; }} /> + {markdownPath ? ( +
    + +
    + ) : null} ); diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index c822d035a6..3c87048654 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -16,6 +16,10 @@ const SECTION_H2 = "font-heading text-[1.375rem] leading-[1.3] font-medium"; * 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", +}; + const TOC: Record = { kysely: [ { id: "choose", label: "When to choose" }, @@ -158,7 +162,7 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child
    - +
    {body}
    {closing} @@ -186,7 +190,7 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child
    diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index ce92e57d4c..8206bdf820 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -21,7 +21,6 @@ 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 { PageAiActions } from "../page-ai-actions"; import { renderFaqAnswer } from "@/lib/faq-answer"; import { APART, @@ -201,7 +200,6 @@ export function getKyselyPageContent() {

    -

    Prisma makes Prisma ORM. Kysely figures come from the pages listed under{" "} diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index a10f6f6ad5..9eb9de7103 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -11,7 +11,7 @@ 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 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground/70 disabled:opacity-60"; + "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"; /** Prompt every Open-with-AI logo prefills. `{PAGE_MD_URL}` is the page's `.md` alternate. */ export function choosePrompt(pageMdUrl: string) { @@ -66,7 +66,7 @@ function toolLinks(markdownUrl: string) { function Mark({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -169,7 +169,11 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) { return ( -

    +
    diff --git a/apps/site/src/app/compare/pricing-table.tsx b/apps/site/src/app/compare/pricing-table.tsx index 4d250133ab..e8229c4362 100644 --- a/apps/site/src/app/compare/pricing-table.tsx +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -58,7 +58,9 @@ export function ComparisonPricingTable({ > diff --git a/apps/site/src/app/globals.css b/apps/site/src/app/globals.css index 54de5e8748..91c4646889 100644 --- a/apps/site/src/app/globals.css +++ b/apps/site/src/app/globals.css @@ -652,12 +652,15 @@ html { /* * Stacked comparison rows below 640px. Unlayered so it beats - * `.answer-prose table { min-width: 720px }`. + * `.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; + min-width: 0 !important; width: 100%; } .answer-prose .compare-stack thead { From f206f161017bc5e652b562d891a7d797c53a4ed0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 11:28:15 +0000 Subject: [PATCH 21/52] Compare: Prisma ORM close is free and open source, not a trial The ORM comparison dek said "Free to start, no credit card required." Prisma ORM is not a trial. Postgres comparisons keep the hosted-plan dek. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 12 +++++++----- apps/site/src/lib/markdown/comparisons.test.tsx | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index 2228e80661..bc55b673b1 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -13,15 +13,17 @@ export const COMPARISON_CTA = { } as const; /** - * Closing action on ORM comparisons (Kysely, Mongoose). The 500K+ line is how - * many people use Prisma ORM; it stays. The buttons go to the ORM quickstart - * and Prisma Postgres rather than a generic console pitch. + * 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: "Free to start, no credit card required.", - bodyMaxWidth: "max-w-[44ch]", + body: "Prisma ORM is free and open source, and always will be.", + bodyMaxWidth: "max-w-[52ch]", checks: COMPARISON_CTA.checks, primaryCta: { label: "Get started with Prisma ORM", diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 90c2279938..eee49d60e7 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -264,13 +264,29 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () COMPARISON_CTA.checks.some((item) => item.label === "Trusted by 500K+ developers globally"), ); assert.equal(ORM_COMPARISON_CTA.checks, COMPARISON_CTA.checks); + assert.equal( + ORM_COMPARISON_CTA.body, + "Prisma ORM is free and open source, and always will be.", + ); + assert.equal(COMPARISON_CTA.body, "Free to start, no credit card required."); const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); assert.ok(markdown.includes("Trusted by 500K+ developers globally")); + assert.ok(markdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.doesNotMatch(markdown, /Free to start, no credit card required/); assert.ok(markdown.includes("\n## Add Prisma ORM to an existing project\n")); assert.ok(markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)")); assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); + const mongoosePage = getMarkdownPage("/compare/mongoose"); + assert.ok(mongoosePage); + const mongooseMarkdown = renderMarkdownDocument(mongoosePage); + assert.ok(mongooseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.doesNotMatch(mongooseMarkdown, /Free to start, no credit card required/); + const convexPage = getMarkdownPage("/compare/convex"); + assert.ok(convexPage); + const convexMarkdown = renderMarkdownDocument(convexPage); + assert.ok(convexMarkdown.includes("Free to start, no credit card required.")); }); test("kysely Markdown carries the switch types, contract, and four commands", () => { From 2c8e5080816bb4f7ab2c6315947002fa44fd78f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 11:33:06 +0000 Subject: [PATCH 22/52] Compare: raise Kysely technical audit with small a11y and perf fixes Pad code lines past the Copy pill, make TOC rows 44px, tab-stop tables only when they scroll, keep copy-link out of tab order until hover or heading focus, unmount closed code tabs, preload Mona Sans Mono from an unbracketed file, and use the border token on table frames. Co-authored-by: rensburg --- apps/site/src/app/compare/anchor-heading.tsx | 18 +++++-- apps/site/src/app/compare/article-toc.tsx | 2 +- apps/site/src/app/compare/compare-table.tsx | 9 ++-- .../site/src/app/compare/kysely/_sections.tsx | 10 ++-- .../site/src/app/compare/kysely/highlight.tsx | 2 +- .../app/compare/kysely/mona-sans-mono.woff2 | Bin 0 -> 37364 bytes apps/site/src/app/compare/kysely/page.tsx | 9 ++-- .../src/app/compare/kysely/same-query.tsx | 4 +- apps/site/src/app/compare/pricing-table.tsx | 9 ++-- .../src/app/compare/table-scroll-region.tsx | 41 ++++++++++++++++ apps/site/src/app/compare/table-styles.ts | 2 +- .../src/lib/markdown/comparisons.test.tsx | 46 ++++++++++++++++++ 12 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 apps/site/src/app/compare/kysely/mona-sans-mono.woff2 create mode 100644 apps/site/src/app/compare/table-scroll-region.tsx diff --git a/apps/site/src/app/compare/anchor-heading.tsx b/apps/site/src/app/compare/anchor-heading.tsx index 29b13de983..50a60707a1 100644 --- a/apps/site/src/app/compare/anchor-heading.tsx +++ b/apps/site/src/app/compare/anchor-heading.tsx @@ -35,6 +35,7 @@ export function AnchorHeading({ 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(); @@ -55,8 +56,18 @@ export function AnchorHeading({ } return ( -
    - +
    setInTabOrder(true)} + onMouseLeave={() => setInTabOrder(false)} + onFocusCapture={() => setInTabOrder(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setInTabOrder(false); + } + }} + > + {children} {copied ? ( diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index 2fb95980ad..8b6d45f186 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -104,7 +104,7 @@ function TocList({ href={`#${item.id}`} aria-current={item.id === active ? "location" : undefined} onClick={() => onChoose(item.id)} - className="-ml-0.5 block border-l-2 border-transparent py-2.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" + className="-ml-0.5 flex min-h-11 items-center border-l-2 border-transparent py-2.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" > {item.label} diff --git a/apps/site/src/app/compare/compare-table.tsx b/apps/site/src/app/compare/compare-table.tsx index 46603dcb46..0b4cdf6f06 100644 --- a/apps/site/src/app/compare/compare-table.tsx +++ b/apps/site/src/app/compare/compare-table.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { SCROLL_HINT, STICKY, TABLE_FRAME, TABLE_STACK, TD, TH_COL, TH_ROW } from "./table-styles"; +import { TableScrollRegion } from "./table-scroll-region"; export type CompareTableRow = { label: string; @@ -24,11 +25,9 @@ export type CompareTableProps = { export function CompareTable({ labelledBy, corner, columns, rows, minWidth }: CompareTableProps) { const columnWidth = `${78 / columns.length}%`; return ( -
    - + ); } diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index f6e2295df8..2f8710eb6f 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -267,17 +267,17 @@ export function getKyselyPageContent() { SQL sent - + {highlight(SAME_QUERY.kysely, "typescript", kyselyGroups)} - + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} - + {highlight(SAME_QUERY.sql, "sql", sqlGroups)} @@ -448,10 +448,10 @@ export function getKyselyPageContent() { contract.prisma - + {highlight(SWITCH_KYSELY_TYPES)} - + {highlight(SWITCH_PRISMA_CONTRACT, "prisma")} diff --git a/apps/site/src/app/compare/kysely/highlight.tsx b/apps/site/src/app/compare/kysely/highlight.tsx index fcb22539a5..224f25cb6e 100644 --- a/apps/site/src/app/compare/kysely/highlight.tsx +++ b/apps/site/src/app/compare/kysely/highlight.tsx @@ -77,7 +77,7 @@ export function highlight( {tokens.flatMap((line, index) => [ index > 0 ? "\n" : null, - + {line.length === 0 ? " " : line.map((token) => ( 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 0000000000000000000000000000000000000000..f4ccd616f12f8ab2cafba174ca37a4f0bcbc5788 GIT binary patch literal 37364 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 literal 0 HcmV?d00001 diff --git a/apps/site/src/app/compare/kysely/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index e5fd579ca8..2cd440027d 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -11,13 +11,16 @@ import { FAQ, KYSELY_AUTHOR, KYSELY_PATH, KYSELY_TITLE } from "./_content"; import { getKyselyArticleFields } from "./facts"; import { KyselyPage } from "./_sections"; -/** The docs' code face. Preload is off so the CSS URL and the preload URL cannot disagree. */ +/** + * 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: "../../../../../../packages/eclipse/src/static/fonts/MonaSansMonoVF[wght].woff2", + src: "./mona-sans-mono.woff2", variable: "--font-code", display: "swap", weight: "200 900", - preload: false, }); export const metadata = getComparisonMetadata("kysely"); diff --git a/apps/site/src/app/compare/kysely/same-query.tsx b/apps/site/src/app/compare/kysely/same-query.tsx index 51b932d51a..90ebb125cd 100644 --- a/apps/site/src/app/compare/kysely/same-query.tsx +++ b/apps/site/src/app/compare/kysely/same-query.tsx @@ -3,8 +3,8 @@ import { useCallback, type MouseEvent, type ReactNode } from "react"; /** - * Hovering or focusing a mapped line in any same-query tab highlights its - * counterparts (force-mounted, so the group is marked even in hidden tabs). + * Hovering or focusing a mapped line highlights 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 }) => { diff --git a/apps/site/src/app/compare/pricing-table.tsx b/apps/site/src/app/compare/pricing-table.tsx index e8229c4362..367058189d 100644 --- a/apps/site/src/app/compare/pricing-table.tsx +++ b/apps/site/src/app/compare/pricing-table.tsx @@ -9,6 +9,7 @@ import { TH_COL, TH_ROW, } from "./table-styles"; +import { TableScrollRegion } from "./table-scroll-region"; export type PricingColumn = { label: string; @@ -49,11 +50,9 @@ export function ComparisonPricingTable({ const columnWidth = `${78 / columns.length}%`; return ( <> -
    -
    + {note ?

    {note}

    : null} ); 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 0000000000..9444e49341 --- /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 index 7cf2e5cb8b..d5ef9e3dfd 100644 --- a/apps/site/src/app/compare/table-styles.ts +++ b/apps/site/src/app/compare/table-styles.ts @@ -1,6 +1,6 @@ /** 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-black/[0.08] outline-none focus-visible:ring-2 focus-visible:ring-ring"; + "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`). diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index eee49d60e7..ba02c411b3 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -6,9 +6,13 @@ import * as mongoose from "@/app/compare/mongoose/_content"; import { COMPARISON_CTA, ORM_COMPARISON_CTA } from "@/app/compare/_content"; import { CompareTable } from "@/app/compare/compare-table"; import { ComparisonPricingTable } from "@/app/compare/pricing-table"; +import { TABLE_FRAME } from "@/app/compare/table-styles"; import { stripFaqAnswerMarkup } from "@/lib/faq-answer"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { Children, isValidElement, type ReactNode } from "react"; import { COMPARISONS, COMPARISON_PATHS } from "@/app/compare/comparisons"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; @@ -380,3 +384,45 @@ test("same-query line groups stay inside each tab's line count", () => { for (const line of group.sql) assert.ok(line >= 0 && line < count(kysely.SAME_QUERY.sql)); } }); + +const compareDir = join(dirname(fileURLToPath(import.meta.url)), "../../app/compare"); + +test("comparison tables use the border token and only tab-stop when they scroll", () => { + assert.match(TABLE_FRAME, /\bborder-border\b/); + assert.doesNotMatch(TABLE_FRAME, /border-black\/\[0\.08\]/); + for (const file of ["compare-table.tsx", "pricing-table.tsx"]) { + const source = readFileSync(join(compareDir, file), "utf8"); + assert.doesNotMatch(source, /tabIndex=\{0\}/); + assert.match(source, /TableScrollRegion/); + } +}); + +test("kysely code tabs stay unmounted when closed, while Markdown still has every snippet", () => { + const source = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); + assert.equal([...source.matchAll(/forceMount=\{false\}/g)].length, 5); + assert.doesNotMatch(source, /forceMount(?!=\{false\})/); + const page = getMarkdownPage("/compare/kysely"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes(`**Kysely**\n\n\`\`\`ts\n${kysely.SAME_QUERY.kysely}\n\`\`\``)); + assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${kysely.SAME_QUERY.prisma}\n\`\`\``)); + assert.ok(markdown.includes(`**SQL sent**\n\n\`\`\`sql\n${kysely.SAME_QUERY.sql}\n\`\`\``)); + assert.ok( + markdown.includes(`**Kysely types**\n\n\`\`\`ts\n${kysely.SWITCH_KYSELY_TYPES}\n\`\`\``), + ); + assert.ok( + markdown.includes(`**contract.prisma**\n\n\`\`\`prisma\n${kysely.SWITCH_PRISMA_CONTRACT}\n\`\`\``), + ); +}); + +test("kysely code lines pad past the Copy pill and preload the unbracketed mono face", () => { + const highlight = readFileSync(join(compareDir, "kysely/highlight.tsx"), "utf8"); + assert.match(highlight, /className="line pl-4 pr-12"/); + const page = readFileSync(join(compareDir, "kysely/page.tsx"), "utf8"); + assert.match(page, /src: "\.\/mona-sans-mono\.woff2"/); + assert.doesNotMatch(page, /preload:\s*false/); + const toc = readFileSync(join(compareDir, "article-toc.tsx"), "utf8"); + assert.match(toc, /min-h-11 items-center/); + const heading = readFileSync(join(compareDir, "anchor-heading.tsx"), "utf8"); + assert.match(heading, /tabIndex=\{inTabOrder \? 0 : -1\}/); +}); From 4966dbb7e7c24684c353fd1d91773b47da540231 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 11:36:38 +0000 Subject: [PATCH 23/52] Compare: omit forceMount so closed Kysely tabs unmount Radix types forceMount as true-only, so forceMount={false} failed the site typecheck. Omitting the prop uses the default unmount behavior. Co-authored-by: rensburg --- apps/site/src/app/compare/kysely/_sections.tsx | 10 +++++----- apps/site/src/lib/markdown/comparisons.test.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 2f8710eb6f..9ae360b2bd 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -267,17 +267,17 @@ export function getKyselyPageContent() { SQL sent - + {highlight(SAME_QUERY.kysely, "typescript", kyselyGroups)} - + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} - + {highlight(SAME_QUERY.sql, "sql", sqlGroups)} @@ -448,10 +448,10 @@ export function getKyselyPageContent() { contract.prisma - + {highlight(SWITCH_KYSELY_TYPES)} - + {highlight(SWITCH_PRISMA_CONTRACT, "prisma")} diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index ba02c411b3..6eb673098b 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -399,8 +399,8 @@ test("comparison tables use the border token and only tab-stop when they scroll" test("kysely code tabs stay unmounted when closed, while Markdown still has every snippet", () => { const source = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); - assert.equal([...source.matchAll(/forceMount=\{false\}/g)].length, 5); - assert.doesNotMatch(source, /forceMount(?!=\{false\})/); + assert.equal([...source.matchAll(/ Date: Sun, 18 Oct 2026 11:41:18 +0000 Subject: [PATCH 24/52] Compare: move Kysely Copy into the tab bar Line padding cannot clear an overlay on overflowing snippets. The copy control now sits in the tab list, and tabbed blocks no longer paint a pill over the code. Co-authored-by: rensburg --- .../site/src/app/compare/kysely/_sections.tsx | 65 +++++++++++-------- apps/site/src/app/compare/tab-code-copy.tsx | 58 +++++++++++++++++ .../src/lib/markdown/comparisons.test.tsx | 4 ++ 3 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 apps/site/src/app/compare/tab-code-copy.tsx diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 9ae360b2bd..5ae4b8f6ea 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -21,6 +21,7 @@ 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, @@ -82,11 +83,13 @@ function databaseStatus(status: DatabaseStatus) { * tab bar), so the same values are set with site utilities. */ const CODE_TABS = "my-0 rounded-[0.625rem] border-black/[0.08] bg-white"; -const CODE_TAB_LIST = "flex flex-row overflow-x-auto px-2"; +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 = - "my-0 rounded-none border-black/[0.08] bg-white [&>div]:py-3.5 [&_.line]:pr-12 [&_button]:relative [&_button]:text-muted-foreground [&_button]:after:absolute [&_button]:after:-inset-2.5 [&_button]:after:content-['']"; + "not-fumadocs-codeblock my-0 rounded-none border-black/[0.08] bg-white [&>div]:py-3.5"; +const CODE_BLOCK_WITH_COPY = + `${CODE_BLOCK} [&>[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", @@ -256,29 +259,32 @@ export function getKyselyPageContent() {

    - - - Kysely - - - Prisma ORM - - - SQL sent - - +
    + + + Kysely + + + Prisma ORM + + + SQL sent + + + +
    - + {highlight(SAME_QUERY.kysely, "typescript", kyselyGroups)} - + {highlight(SAME_QUERY.prisma, "typescript", prismaGroups)} - + {highlight(SAME_QUERY.sql, "sql", sqlGroups)} @@ -440,24 +446,29 @@ export function getKyselyPageContent() {

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

    - - - Kysely types - - - contract.prisma - - +
    + + + Kysely types + + + contract.prisma + + + +
    - {highlight(SWITCH_KYSELY_TYPES)} + + {highlight(SWITCH_KYSELY_TYPES)} + - + {highlight(SWITCH_PRISMA_CONTRACT, "prisma")}
    - {highlight(SWITCH_COMMANDS, "bash")} + {highlight(SWITCH_COMMANDS, "bash")}
    1. Later changes go through{" "} 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 0000000000..1d076e872d --- /dev/null +++ b/apps/site/src/app/compare/tab-code-copy.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, Clipboard } from "lucide-react"; + +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), 1600); + } + + return ( + + ); +} diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 6eb673098b..85c0b22ea3 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -418,6 +418,10 @@ test("kysely code tabs stay unmounted when closed, while Markdown still has ever test("kysely code lines pad past the Copy pill and preload the unbracketed mono face", () => { const highlight = readFileSync(join(compareDir, "kysely/highlight.tsx"), "utf8"); assert.match(highlight, /className="line pl-4 pr-12"/); + const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); + assert.match(sections, /not-fumadocs-codeblock/); + assert.match(sections, / Date: Sun, 18 Oct 2026 14:23:39 +0000 Subject: [PATCH 25/52] Compare: Kysely design pass for legend, copy, and open-in Make same-query line mapping visible without hover, keep Copied on screen long enough to notice, lead #choose with the lists, align the ORM close with library bullets, unify table chrome, and add compact Copy page plus Open-in for the five public AIs. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 6 +- apps/site/src/app/compare/anchor-heading.tsx | 3 +- apps/site/src/app/compare/compare-table.tsx | 13 +- apps/site/src/app/compare/copied.ts | 2 + apps/site/src/app/compare/kysely/_content.ts | 9 +- .../site/src/app/compare/kysely/_sections.tsx | 83 +++++++------ .../site/src/app/compare/kysely/highlight.tsx | 12 +- .../src/app/compare/kysely/same-query.tsx | 11 +- apps/site/src/app/compare/page-ai-actions.tsx | 115 ++++++++++++++++++ apps/site/src/app/compare/pricing-table.tsx | 3 +- apps/site/src/app/compare/tab-code-copy.tsx | 7 +- apps/site/src/app/compare/table-styles.ts | 5 +- .../src/lib/markdown/comparison-content.ts | 10 +- .../src/lib/markdown/comparisons.test.tsx | 66 ++++++++-- 14 files changed, 281 insertions(+), 64 deletions(-) create mode 100644 apps/site/src/app/compare/copied.ts create mode 100644 apps/site/src/app/compare/page-ai-actions.tsx diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index bc55b673b1..b4e03a0668 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -24,7 +24,11 @@ export const ORM_COMPARISON_CTA = { headlineMaxWidth: "max-w-[28ch]", body: "Prisma ORM is free and open source, and always will be.", bodyMaxWidth: "max-w-[52ch]", - checks: COMPARISON_CTA.checks, + 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", diff --git a/apps/site/src/app/compare/anchor-heading.tsx b/apps/site/src/app/compare/anchor-heading.tsx index 50a60707a1..e161630a23 100644 --- a/apps/site/src/app/compare/anchor-heading.tsx +++ b/apps/site/src/app/compare/anchor-heading.tsx @@ -2,6 +2,7 @@ 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); @@ -52,7 +53,7 @@ export function AnchorHeading({ } setCopied(true); clearTimeout(timer.current); - timer.current = setTimeout(() => setCopied(false), 1600); + timer.current = setTimeout(() => setCopied(false), COPIED_VISIBLE_MS); } return ( diff --git a/apps/site/src/app/compare/compare-table.tsx b/apps/site/src/app/compare/compare-table.tsx index 0b4cdf6f06..4de82d01d7 100644 --- a/apps/site/src/app/compare/compare-table.tsx +++ b/apps/site/src/app/compare/compare-table.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; -import { SCROLL_HINT, STICKY, TABLE_FRAME, TABLE_STACK, TD, TH_COL, TH_ROW } from "./table-styles"; +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 = { @@ -48,7 +57,7 @@ export function CompareTable({ labelledBy, corner, columns, rows, minWidth }: Co
    {row.label}
    {row.label}
    - - - - - - - - - {COMPARE_ROWS.map((row) => ( - - - - - - ))} - -
    - Feature - - Mongoose - - Prisma ORM -
    - {row.feature} - - {row.mongoose} - - {renderFaqAnswer(row.prisma)} -
    +
    + + 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 + + + Prisma ORM + + + MongoDB sent + + +
    -
    -

    - 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 - - . + + + {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.

    -
    +
    -
    -
    -
    -

    - 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 - {" "} - describe that file as the contract. MongoDB support is early access, and the limits - are in the table above. -

    -
      - {PRISMA_FITS.map((item) => ( -
    • - {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. + 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)], + }))} + />
    -
    -
    -

    - What sets Prisma ORM apart -

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

    {item.title}

    -

    - {item.body} -

    -
    - ))} -
    -
    +
    + + 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. +

    + ({ + 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:{" "} + + Mongoose README + + ,{" "} + + Prisma ORM 8 supported databases + + , and{" "} + + Prisma ORM 7 supported databases + + , {CHECKED}. +

    -
    -
    -

    - 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. -

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

    {renderFaqAnswer(item.body)}

    -
    + ))}
    -
    -
    -

    - When Prisma ORM is the wrong choice -

    -
      - {WRONG_CHOICE.map((item) => ( -
    • - {renderFaqAnswer(item)} -
    • - ))} -
    -
    +
    + + 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 + {" "} + lists the paid plans. + + } + />
    -
    -
    -
    -

    - 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. -
    -
    -
    +
    + + When is Prisma ORM the wrong choice? + +
      + {WRONG_CHOICE.map((item) => ( +
    • {renderFaqAnswer(item)}
    • + ))} +
    -
    -
    -

    - 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} -
    -
    - ))} -
    -
    +
    + + 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)], + }))} + />
    -
    -
    -

    - Frequently asked questions -

    -
    - - {FAQ.map((faq, index) => ( - - - {faq.question} - - - {renderFaqAnswer(faq.answer)} - - - ))} - +
    + + 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. +
    +
    + +
    + + 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 do teams say about Prisma ORM? + +

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

    +
    + {PROOF.map((item) => ( +
    + +
    + “{item.quote}” +
    +
    + {item.name}, {item.role},{" "} + {item.company} +
    +
    + ))}
    + +
    + + 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} +
    • + ))} +
    +
    + ))} +
    +
    , ]; } 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 0000000000..919ec7eae3 --- /dev/null +++ b/apps/site/src/app/compare/mongoose/facts.ts @@ -0,0 +1,106 @@ +import { + COMPARE_ROWS, + DATABASE_MATRIX, + DATABASE_SOURCES, + MONGOOSE_PATH, + MONGOOSE_PUBLISHED, + MONGOOSE_UPDATED, + PRICING, + TOOLS, +} from "./_content"; + +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() { + return { + datePublished: MONGOOSE_PUBLISHED.iso, + dateModified: MONGOOSE_UPDATED.iso, + about: getMongooseAboutEntities(), + }; +} diff --git a/apps/site/src/app/compare/mongoose/page.tsx b/apps/site/src/app/compare/mongoose/page.tsx index a0f2ba92ff..e0b8bbae66 100644 --- a/apps/site/src/app/compare/mongoose/page.tsx +++ b/apps/site/src/app/compare/mongoose/page.tsx @@ -1,20 +1,62 @@ +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 { 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: { + "@type": "Person", + name: MONGOOSE_AUTHOR.name, + url: new URL(MONGOOSE_AUTHOR.path, getBaseUrl()).toString(), + }, + }, + ...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 index 9eb9de7103..f30a26503f 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -13,19 +13,30 @@ 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"; -/** Prompt every Open-with-AI logo prefills. `{PAGE_MD_URL}` is the page's `.md` alternate. */ -export function choosePrompt(pageMdUrl: string) { - return `Read this article and treat it as the source of truth: - -${pageMdUrl} - -I am choosing between Prisma ORM and Kysely for a TypeScript app. Using only what the article states, plus my answers: +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, 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.`; + +/** 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/mongoose.md") ? MONGOOSE_PROMPT : KYSELY_PROMPT; + return `Read this article and treat it as the source of truth: + +${pageMdUrl} + +${body}`; } function toolLinks(markdownUrl: string) { diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index b6204404c8..af7daba9cd 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -17,7 +17,7 @@ export function getComparisonMetadata(slug: ComparisonSlug) { const types: Record = { "text/markdown": new URL(`${page.path}.md`, baseUrl).toString(), }; - if (slug === "kysely") { + if (slug === "kysely" || slug === "mongoose") { types["application/json"] = new URL(`${page.path}.json`, baseUrl).toString(); } return { diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 57c13c1cb8..6cc15418f2 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -3,6 +3,8 @@ import * as kysely from "@/app/compare/kysely/_content"; import { getKyselyArticleFields, getKyselyFacts } from "@/app/compare/kysely/facts"; import { GET as getKyselyFactsResponse } from "@/app/compare/kysely.json/route"; import * as mongoose from "@/app/compare/mongoose/_content"; +import { getMongooseArticleFields, getMongooseFacts } from "@/app/compare/mongoose/facts"; +import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; import { COMPARISON_CTA, ORM_COMPARISON_CTA } from "@/app/compare/_content"; import { CompareTable } from "@/app/compare/compare-table"; import { ComparisonPricingTable } from "@/app/compare/pricing-table"; @@ -490,9 +492,226 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = assert.match(ai, /Opens ChatGPT with this comparison and a prompt to help you choose/); assert.match(ai, /Read this article and treat it as the source of truth:/); assert.match(ai, /I am choosing between Prisma ORM and Kysely for a TypeScript app/); + assert.match(ai, /I am choosing between Prisma ORM and Mongoose for a TypeScript app/); assert.doesNotMatch(ai, /I want to ask questions about it/); assert.match(ai, /claude\.ai\/new/); assert.match(ai, /gemini\.google\.com\/app/); assert.match(ai, /perplexity\.ai\/search\/new/); assert.match(ai, /grok\.com/); }); + +test("Mongoose Markdown carries the byline and each code tab as a labelled TypeScript fence", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok( + markdown.includes( + `By [${mongoose.MONGOOSE_AUTHOR.name}](${mongoose.MONGOOSE_AUTHOR.path}) · Updated ${mongoose.MONGOOSE_UPDATED.label}`, + ), + ); + assert.ok(markdown.includes(`**Mongoose**\n\n\`\`\`ts\n${mongoose.SAME_QUERY.mongoose}\n\`\`\``)); + assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${mongoose.SAME_QUERY.prisma}\n\`\`\``)); + assert.ok( + markdown.includes(`**MongoDB sent**\n\n\`\`\`ts\n${mongoose.SAME_QUERY.mongo}\n\`\`\``), + ); +}); + +test("mongoose Markdown keeps yes and no marks out of the feature table, and anchored headings intact", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const hosted = markdown.split("\n").find((line) => line.startsWith("| Hosted database |")); + assert.ok(hosted); + assert.ok( + hosted.startsWith("| Hosted database | None. Mongoose is a library. You bring MongoDB. |"), + ); + assert.doesNotMatch(hosted, /\b(Yes|No)\b/); + assert.ok(markdown.includes("\n## When should I choose Mongoose or Prisma ORM?\n")); + assert.ok(markdown.includes("\n### The same ORM client on PostgreSQL\n")); + assert.ok(markdown.includes("\n### A migration plan that does not need the database\n")); + assert.doesNotMatch(markdown, /Copy link|Copied/); +}); + +test("mongoose Markdown carries the database matrix and the task table as Markdown tables", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes("\n## Which databases do Prisma ORM and Mongoose support?\n")); + assert.ok(markdown.includes("| Database | Mongoose | Prisma ORM 8 | Prisma ORM 7 |")); + for (const row of mongoose.DATABASE_MATRIX) { + const cells = [row.mongoose, row.orm8, row.orm7].map((status) => status ?? "No"); + assert.ok( + markdown.includes(`| ${row.database} | ${cells.join(" | ")} |`), + `missing matrix row: ${row.database}`, + ); + } + assert.ok( + markdown.includes("| MongoDB | Supported | Early access | Use 6.19 |"), + "MongoDB ORM 7 follows the migrate guide, not the v7 databases page", + ); + assert.ok( + markdown.includes("](/compare/mongoose#databases)"), + "Databases row links to the matrix", + ); + + assert.ok(markdown.includes("\n## How does each task look in Mongoose and Prisma ORM?\n")); + assert.ok(markdown.includes("| Task | Mongoose | Prisma ORM |")); + const plain = plainMarkdown(markdown); + for (const row of mongoose.SAME_JOB) { + const line = markdown.split("\n").find((text) => text.startsWith(`| ${row.task} |`)); + assert.ok(line, `missing task row: ${row.task}`); + assert.ok( + plain.includes(stripFaqAnswerMarkup(row.mongoose)), + `missing Mongoose cell: ${row.task}`, + ); + assert.ok(plain.includes(stripFaqAnswerMarkup(row.prisma)), `missing Prisma cell: ${row.task}`); + } +}); + +test("mongoose Markdown renders the remaining cards and drops the fit columns", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.equal(mongoose.APART.length, 2); + for (const card of mongoose.APART) { + assert.ok(markdown.includes(`\n### ${card.title}\n`), `missing card heading: ${card.title}`); + } + assert.doesNotMatch(markdown, /When is Mongoose the better fit\?/); + assert.doesNotMatch(markdown, /When is Prisma ORM the better fit\?/); + assert.doesNotMatch(markdown, /Where each fits/); + assert.doesNotMatch(markdown, /choose-mongoose/); +}); + +test("mongoose Markdown carries pricing as a table followed by the free tier note", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const section = markdown + .split("\n## How much do Prisma ORM and Mongoose cost?\n")[1] + ?.split("\n## ")[0]; + assert.ok(section, "missing pricing section"); + assert.ok(section.includes("| Cost | Mongoose | Prisma ORM |")); + for (const row of mongoose.PRICING) { + const line = section.split("\n").find((text) => text.startsWith(`| ${row.label} |`)); + assert.ok(line, `missing pricing row: ${row.label}`); + assert.ok(plainMarkdown(line).includes(stripFaqAnswerMarkup(row.prisma))); + } + assert.match( + section.trimEnd(), + /\|\n\nPrisma Postgres has a Free plan: \$0 a month for 200,000 operations, 2 GB of storage, and 50 databases\. The \[pricing page\]\(\/pricing\) lists the paid plans\.$/, + ); +}); + +test("mongoose Markdown opens with the check date and does not add a period after the title's question mark", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok(markdown.includes("Checked October 2026.")); + assert.ok(markdown.includes(`dateModified: ${mongoose.MONGOOSE_UPDATED.iso}`)); + assert.doesNotMatch(markdown, /code\?\./); + assert.doesNotMatch(markdown, /`[^`\n]+`\\\./); + assert.doesNotMatch(markdown, /\]\([^)]+\)\\\./); +}); + +test("mongoose Markdown carries the switch schema, contract, and commands", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + assert.ok( + markdown.includes( + `**Mongoose schema**\n\n\`\`\`ts\n${mongoose.SWITCH_MONGOOSE_SCHEMA}\n\`\`\``, + ), + ); + assert.ok( + markdown.includes( + `**contract.prisma**\n\n\`\`\`prisma\n${mongoose.SWITCH_PRISMA_CONTRACT}\n\`\`\``, + ), + ); + assert.ok(markdown.includes(`\`\`\`bash\n${mongoose.SWITCH_COMMANDS}\n\`\`\``)); + assert.ok(markdown.includes("orm init --target mongodb")); + assert.doesNotMatch(markdown, /contract infer --output/); +}); + +test("mongoose JSON facts carry a source and dateChecked on every value", async () => { + const facts = getMongooseFacts(); + assert.equal(facts.path, "/compare/mongoose"); + assert.equal(facts.dateChecked, mongoose.MONGOOSE_UPDATED.iso); + assert.equal(facts.tools.mongoose.softwareVersion, "9.11.2"); + assert.equal(facts.features.length, mongoose.COMPARE_ROWS.length); + for (const row of [...facts.features, ...facts.pricing]) { + assert.match(row.mongoose.source, /^https?:\/\//); + assert.match(row.prisma.source, /^https?:\/\//); + assert.equal(row.mongoose.dateChecked, facts.dateChecked); + assert.equal(row.prisma.dateChecked, facts.dateChecked); + } + for (const row of facts.databases) { + for (const side of [row.mongoose, row.orm8, row.orm7] as const) { + assert.match(side.source, /^https?:\/\//); + assert.equal(side.dateChecked, facts.dateChecked); + assert.ok(side.value === "No" || typeof side.value === "string"); + } + } + const mongo = facts.databases.find((row) => row.database === "MongoDB"); + assert.equal(mongo?.orm7.value, "Use 6.19"); + assert.match(mongo?.orm7.source ?? "", /guides\/v7\/switch-to-prisma-orm\/from-mongoose/); + const response = getMongooseFactsResponse(); + assert.match(response.headers.get("content-type") ?? "", /^application\/json/); + assert.deepEqual(await response.json(), facts); + + const fields = getMongooseArticleFields(); + assert.equal(fields.datePublished, mongoose.MONGOOSE_PUBLISHED.iso); + assert.equal(fields.dateModified, mongoose.MONGOOSE_UPDATED.iso); + assert.equal(fields.about[0]?.name, "Mongoose"); + assert.equal(fields.about[1]?.name, "Prisma ORM"); +}); + +test("mongoose metadata advertises Markdown and JSON alternates", () => { + const types = getComparisonMetadata("mongoose").alternates?.types; + assert.ok(types); + assert.match(types["text/markdown"] ?? "", /\/compare\/mongoose\.md$/); + assert.match(types["application/json"] ?? "", /\/compare\/mongoose\.json$/); +}); + +test("mongoose same-query line groups stay inside each tab's line count", () => { + const count = (code: string) => code.split("\n").length; + for (const group of mongoose.SAME_QUERY_LINE_GROUPS) { + for (const line of group.mongoose) + assert.ok(line >= 0 && line < count(mongoose.SAME_QUERY.mongoose)); + for (const line of group.prisma) + assert.ok(line >= 0 && line < count(mongoose.SAME_QUERY.prisma)); + for (const line of group.mongo) assert.ok(line >= 0 && line < count(mongoose.SAME_QUERY.mongo)); + } +}); + +test("mongoose code tabs stay unmounted when closed, while Markdown still has every snippet", () => { + const source = readFileSync(join(compareDir, "mongoose/_sections.tsx"), "utf8"); + assert.equal([...source.matchAll(/ { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const choose = markdown.slice( + markdown.indexOf("## When should I choose Mongoose or Prisma ORM?"), + markdown.indexOf("## What does the same query look like in each?"), + ); + assert.match(choose, /### Mongoose/); + assert.match(choose, /a contract is the file that describes your collections or tables/); + assert.match(markdown, /author include/); + assert.match(markdown, /published filter/); + const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /mongoose: "\/compare\/mongoose\.md"/); + const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); + assert.match(ai, /pageMdUrl.includes\("\/compare\/mongoose\.md"\)/); + assert.match(ai, /I am choosing between Prisma ORM and Mongoose for a TypeScript app/); + assert.match(ai, /schema lives next to the model in application code/); + assert.doesNotMatch(ai, /Open-with-AI prompt pointed at `\/compare\/kysely\.md` for mongoose/); +}); diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index 30918158cc..8ecab9a09a 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -10,6 +10,7 @@ import { import { getConvexPageContent } from "@/app/compare/convex/_sections"; import { CHECKED as KYSELY_CHECKED, KYSELY_UPDATED } from "@/app/compare/kysely/_content"; import { getKyselyPageContent } from "@/app/compare/kysely/_sections"; +import { CHECKED as MONGOOSE_CHECKED, MONGOOSE_UPDATED } from "@/app/compare/mongoose/_content"; import { getMongoosePageContent } from "@/app/compare/mongoose/_sections"; import type { MarkdownPage } from "@/lib/markdown-pages"; import { heading, joinBlocks } from "./blocks"; @@ -59,7 +60,9 @@ export function getComparisonMarkdownPages(): Record blocks.slice(2).join("\n\n"), ...(entry.slug === "kysely" ? { checked: KYSELY_CHECKED, dateModified: KYSELY_UPDATED.iso } - : {}), + : entry.slug === "mongoose" + ? { checked: MONGOOSE_CHECKED, dateModified: MONGOOSE_UPDATED.iso } + : {}), } satisfies MarkdownPage, ]; }), From ae97570b9466ece8579c4e966f6bd4bd23bda085 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:11:59 +0000 Subject: [PATCH 31/52] Compare: full-bleed hero, reading time, and confirmed author socials Bleed the comparison hero band to the viewport, keep title/dek/CTAs on the 54rem measure, and add a 225 wpm reading time. Martin's byline and Article JSON-LD Person.sameAs use the confirmed X, LinkedIn, and GitHub profiles. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 41 ++--- apps/site/src/app/compare/byline.tsx | 146 ++++++++++++++++++ .../site/src/app/compare/kysely/_sections.tsx | 40 ++--- apps/site/src/app/compare/kysely/facts.ts | 4 + apps/site/src/app/compare/kysely/page.tsx | 7 +- .../src/app/compare/mongoose/_sections.tsx | 40 ++--- apps/site/src/app/compare/mongoose/facts.ts | 4 + apps/site/src/app/compare/mongoose/page.tsx | 7 +- apps/site/src/app/compare/reading-time.ts | 47 ++++++ .../src/lib/markdown/comparisons.test.tsx | 64 +++++++- 10 files changed, 319 insertions(+), 81 deletions(-) create mode 100644 apps/site/src/app/compare/byline.tsx create mode 100644 apps/site/src/app/compare/reading-time.ts diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 2fd441b283..41da13ae38 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -131,8 +131,9 @@ function articleParts(children: ReactNode) { } /** - * The same article frame as the product guides: header card, heading list, - * reading column, closing action, and related links. The directory stays separate. + * The same article frame as the product guides: full-bleed hero band, heading + * list, reading column, closing action, and related links. Title, dek, CTAs + * and byline stay on the existing content measure. The directory stays separate. */ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; children: ReactNode }) { const entry = COMPARISONS.find((item) => item.slug === slug); @@ -141,23 +142,25 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child const related = COMPARISONS.filter((item) => item.slug !== slug); return ( -
    -
    -
    -
    - - {intro} +
    +
    +
    +
    +
    + + {intro} +
    diff --git a/apps/site/src/app/compare/byline.tsx b/apps/site/src/app/compare/byline.tsx new file mode 100644 index 0000000000..96fc1c4326 --- /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/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 8206bdf820..c7bae4d9a3 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -15,6 +15,8 @@ import { import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { comparisonByline } from "../byline"; +import { articleReadingStats } from "../reading-time"; import { CompareTable } from "../compare-table"; import { ComparisonPricingTable } from "../pricing-table"; import { QUIET_LINK, QUIET_NOTE } from "../table-styles"; @@ -152,7 +154,7 @@ function SourceLink({ href, children }: { href: string; children: string }) { } /** Build the shared content tree for the HTML page and its Markdown rendition. */ -export function getKyselyPageContent() { +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"); @@ -176,30 +178,7 @@ export function getKyselyPageContent() { keep yourself.

    {comparisonHeroCtas("orm")} -

    - - - - By{" "} - - {KYSELY_AUTHOR.name} - - {" "} - ·{" "} - - Updated - - -

    + {comparisonByline({ author: KYSELY_AUTHOR, updated: KYSELY_UPDATED, minutes })}

    Prisma makes Prisma ORM. Kysely figures come from the pages listed under{" "} @@ -587,6 +566,17 @@ export function getKyselyPageContent() { ]; } +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 in the guide article frame. */ export function KyselyPage() { return {getKyselyPageContent()}; diff --git a/apps/site/src/app/compare/kysely/facts.ts b/apps/site/src/app/compare/kysely/facts.ts index 0e595afab5..bf11decad0 100644 --- a/apps/site/src/app/compare/kysely/facts.ts +++ b/apps/site/src/app/compare/kysely/facts.ts @@ -8,6 +8,7 @@ import { PRICING, TOOLS, } from "./_content"; +import { getKyselyReadingStats } from "./_sections"; export type FactValue = { value: string | null; @@ -98,9 +99,12 @@ export function getKyselyAboutEntities() { /** 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/page.tsx b/apps/site/src/app/compare/kysely/page.tsx index 2cd440027d..9f4a538c85 100644 --- a/apps/site/src/app/compare/kysely/page.tsx +++ b/apps/site/src/app/compare/kysely/page.tsx @@ -7,6 +7,7 @@ import { } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; 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"; @@ -38,11 +39,7 @@ function articleStructuredData() { ...article, ...getKyselyArticleFields(), image: getComparisonOgImageUrl("kysely"), - author: { - "@type": "Person", - name: KYSELY_AUTHOR.name, - url: new URL(KYSELY_AUTHOR.path, getBaseUrl()).toString(), - }, + author: comparisonAuthorPerson(KYSELY_AUTHOR, getBaseUrl()), }, ...rest, ], diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index ac10f4529d..e52fccf2c2 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -15,6 +15,8 @@ import { import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { comparisonByline } from "../byline"; +import { articleReadingStats } from "../reading-time"; import { CompareTable } from "../compare-table"; import { ComparisonPricingTable } from "../pricing-table"; import { QUIET_LINK, QUIET_NOTE } from "../table-styles"; @@ -151,7 +153,7 @@ function SourceLink({ href, children }: { href: string; children: string }) { } /** Build the shared content tree for the HTML page and its Markdown rendition. */ -export function getMongoosePageContent() { +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"); @@ -175,30 +177,7 @@ export function getMongoosePageContent() { and query with find() and populate().

    {comparisonHeroCtas("orm")} -

    - - - - By{" "} - - {MONGOOSE_AUTHOR.name} - - {" "} - ·{" "} - - Updated - - -

    + {comparisonByline({ author: MONGOOSE_AUTHOR, updated: MONGOOSE_UPDATED, minutes })}

    Prisma makes Prisma ORM. Mongoose figures come from the pages listed under{" "} @@ -593,6 +572,17 @@ export function getMongoosePageContent() { ]; } +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 in the guide article frame. */ export function MongoosePage() { return {getMongoosePageContent()}; diff --git a/apps/site/src/app/compare/mongoose/facts.ts b/apps/site/src/app/compare/mongoose/facts.ts index 919ec7eae3..792e3850d7 100644 --- a/apps/site/src/app/compare/mongoose/facts.ts +++ b/apps/site/src/app/compare/mongoose/facts.ts @@ -8,6 +8,7 @@ import { PRICING, TOOLS, } from "./_content"; +import { getMongooseReadingStats } from "./_sections"; export type FactValue = { value: string | null; @@ -98,9 +99,12 @@ export function getMongooseAboutEntities() { /** 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 e0b8bbae66..faae1401c4 100644 --- a/apps/site/src/app/compare/mongoose/page.tsx +++ b/apps/site/src/app/compare/mongoose/page.tsx @@ -7,6 +7,7 @@ import { } from "@/lib/comparison-metadata"; import { createFaqStructuredData } from "@/lib/structured-data"; 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"; @@ -37,11 +38,7 @@ function articleStructuredData() { ...article, ...getMongooseArticleFields(), image: getComparisonOgImageUrl("mongoose"), - author: { - "@type": "Person", - name: MONGOOSE_AUTHOR.name, - url: new URL(MONGOOSE_AUTHOR.path, getBaseUrl()).toString(), - }, + author: comparisonAuthorPerson(MONGOOSE_AUTHOR, getBaseUrl()), }, ...rest, ], 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 0000000000..90d52c7545 --- /dev/null +++ b/apps/site/src/app/compare/reading-time.ts @@ -0,0 +1,47 @@ +import { isValidElement, type ReactNode } from "react"; + +/** + * 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<{ children?: ReactNode; "aria-hidden"?: boolean | "true" }>(value)) { + return; + } + if (value.props["aria-hidden"] === true || value.props["aria-hidden"] === "true") return; + 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/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 6cc15418f2..b372fdef95 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -1,9 +1,13 @@ import * as convex from "@/app/compare/convex/_content"; import * as kysely from "@/app/compare/kysely/_content"; +import { MARTIN_SOCIALS, comparisonAuthorPerson } from "@/app/compare/byline"; import { getKyselyArticleFields, getKyselyFacts } from "@/app/compare/kysely/facts"; import { GET as getKyselyFactsResponse } from "@/app/compare/kysely.json/route"; +import { getKyselyReadingStats } from "@/app/compare/kysely/_sections"; import * as mongoose from "@/app/compare/mongoose/_content"; import { getMongooseArticleFields, getMongooseFacts } from "@/app/compare/mongoose/facts"; +import { getMongooseReadingStats } from "@/app/compare/mongoose/_sections"; +import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; import { COMPARISON_CTA, ORM_COMPARISON_CTA } from "@/app/compare/_content"; import { CompareTable } from "@/app/compare/compare-table"; @@ -18,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { Children, isValidElement, type ReactNode } from "react"; import { COMPARISONS, COMPARISON_PATHS } from "@/app/compare/comparisons"; import { getComparisonMetadata, getComparisonStructuredData } from "@/lib/comparison-metadata"; +import { getBaseUrl } from "@/lib/url"; import { getAgentMarkdownRewritePathname } from "@/lib/agent-markdown"; import { getMarkdownPage, renderMarkdownDocument } from "@/lib/markdown-pages"; import { renderComparisonContent } from "./comparison-content"; @@ -116,11 +121,18 @@ test("Kysely Markdown carries the byline and each code tab as a labelled TypeScr const page = getMarkdownPage("/compare/kysely"); assert.ok(page); const markdown = renderMarkdownDocument(page); + const minutes = getKyselyReadingStats().minutes; assert.ok( markdown.includes( - `By [${kysely.KYSELY_AUTHOR.name}](${kysely.KYSELY_AUTHOR.path}) · Updated ${kysely.KYSELY_UPDATED.label}`, + `By [${kysely.KYSELY_AUTHOR.name}](${kysely.KYSELY_AUTHOR.path}) · Updated ${kysely.KYSELY_UPDATED.label} · ${minutes} min read`, ), ); + for (const social of MARTIN_SOCIALS) { + assert.ok( + markdown.includes(`[${social.label}](${social.href})`), + `missing ${social.label} profile`, + ); + } assert.ok(markdown.includes(`**Kysely**\n\n\`\`\`ts\n${kysely.SAME_QUERY.kysely}\n\`\`\``)); assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${kysely.SAME_QUERY.prisma}\n\`\`\``)); assert.ok(markdown.includes(`**SQL sent**\n\n\`\`\`sql\n${kysely.SAME_QUERY.sql}\n\`\`\``)); @@ -345,12 +357,39 @@ test("kysely JSON facts carry a source and dateChecked on every value", async () assert.deepEqual(await response.json(), facts); const fields = getKyselyArticleFields(); + const stats = getKyselyReadingStats(); assert.equal(fields.datePublished, kysely.KYSELY_PUBLISHED.iso); assert.equal(fields.dateModified, kysely.KYSELY_UPDATED.iso); + assert.equal(WORDS_PER_MINUTE, 225); + assert.equal(fields.wordCount, stats.wordCount); + assert.equal(fields.timeRequired, `PT${stats.minutes}M`); + assert.equal(stats.minutes, readingMinutes(stats.wordCount)); + assert.ok(stats.wordCount > 800); assert.equal(fields.about[0]?.name, "Kysely"); assert.equal(fields.about[1]?.name, "Prisma ORM"); }); +test("Kysely and Mongoose Article JSON-LD authors are a Person with confirmed sameAs", () => { + const hrefs = MARTIN_SOCIALS.map((social) => social.href); + assert.deepEqual(hrefs, [ + "https://x.com/vanrensbird", + "https://www.linkedin.com/in/martjvr/", + "https://github.com/vanrensbird", + ]); + for (const author of [kysely.KYSELY_AUTHOR, mongoose.MONGOOSE_AUTHOR]) { + const person = comparisonAuthorPerson(author, getBaseUrl()); + assert.equal(person["@type"], "Person"); + assert.equal(person.name, author.name); + assert.equal(person.url, new URL(author.path, getBaseUrl()).toString()); + assert.deepEqual(person.sameAs, hrefs); + } + for (const page of ["kysely/page.tsx", "mongoose/page.tsx"]) { + const source = readFileSync(join(compareDir, page), "utf8"); + assert.match(source, /comparisonAuthorPerson\(/); + assert.doesNotMatch(source, /sameAs:\s*\[/); + } +}); + test("kysely metadata advertises Markdown and JSON alternates", () => { const types = getComparisonMetadata("kysely").alternates?.types; assert.ok(types); @@ -476,6 +515,15 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = assert.doesNotMatch(sections, /PageAiActions/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /kysely: "\/compare\/kysely\.md"/); + assert.match(article, /header className="bg-muted\/50"/); + assert.match(article, /overflow-x-clip/); + assert.doesNotMatch(article, /rounded-2xl bg-muted\/50/); + assert.doesNotMatch(article, /w-screen|100vw/); + const byline = readFileSync(join(compareDir, "byline.tsx"), "utf8"); + assert.match(byline, /linkedin\.com\/in\/martjvr/); + assert.match(byline, /x\.com\/vanrensbird/); + assert.match(byline, /github\.com\/vanrensbird/); + assert.match(byline, /size-11/); const toc = readFileSync(join(compareDir, "article-toc.tsx"), "utf8"); assert.match(toc, /PageAiActions/); assert.match(toc, /markdownPath/); @@ -504,11 +552,18 @@ test("Mongoose Markdown carries the byline and each code tab as a labelled TypeS const page = getMarkdownPage("/compare/mongoose"); assert.ok(page); const markdown = renderMarkdownDocument(page); + const minutes = getMongooseReadingStats().minutes; assert.ok( markdown.includes( - `By [${mongoose.MONGOOSE_AUTHOR.name}](${mongoose.MONGOOSE_AUTHOR.path}) · Updated ${mongoose.MONGOOSE_UPDATED.label}`, + `By [${mongoose.MONGOOSE_AUTHOR.name}](${mongoose.MONGOOSE_AUTHOR.path}) · Updated ${mongoose.MONGOOSE_UPDATED.label} · ${minutes} min read`, ), ); + for (const social of MARTIN_SOCIALS) { + assert.ok( + markdown.includes(`[${social.label}](${social.href})`), + `missing ${social.label} profile`, + ); + } assert.ok(markdown.includes(`**Mongoose**\n\n\`\`\`ts\n${mongoose.SAME_QUERY.mongoose}\n\`\`\``)); assert.ok(markdown.includes(`**Prisma ORM**\n\n\`\`\`ts\n${mongoose.SAME_QUERY.prisma}\n\`\`\``)); assert.ok( @@ -659,8 +714,13 @@ test("mongoose JSON facts carry a source and dateChecked on every value", async assert.deepEqual(await response.json(), facts); const fields = getMongooseArticleFields(); + const stats = getMongooseReadingStats(); assert.equal(fields.datePublished, mongoose.MONGOOSE_PUBLISHED.iso); assert.equal(fields.dateModified, mongoose.MONGOOSE_UPDATED.iso); + assert.equal(fields.wordCount, stats.wordCount); + assert.equal(fields.timeRequired, `PT${stats.minutes}M`); + assert.equal(stats.minutes, readingMinutes(stats.wordCount)); + assert.ok(stats.wordCount > 800); assert.equal(fields.about[0]?.name, "Mongoose"); assert.equal(fields.about[1]?.name, "Prisma ORM"); }); From 7223e0a02a42e0431abfcc1d33650ab52c05a24d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:20:01 +0000 Subject: [PATCH 32/52] Compare: match the Codex guide hero card Use the product-guide hero chrome: padding below the site header, a rounded muted card on max-w-site, title/dek/CTAs/byline still on the content measure. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 44 +++++++++---------- .../src/lib/markdown/comparisons.test.tsx | 5 ++- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 41da13ae38..211572e6a3 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -131,9 +131,11 @@ function articleParts(children: ReactNode) { } /** - * The same article frame as the product guides: full-bleed hero band, heading - * list, reading column, closing action, and related links. Title, dek, CTAs - * and byline stay on the existing content measure. The directory stays separate. + * The same article frame as the product guides: a rounded muted hero card that + * breaks out of the reading column (same chrome as `/postgres/codex-database-provisioning`), + * heading list, reading column, closing action, and related links. Title, dek, + * CTAs and byline stay on the existing content measure. The directory stays + * separate. */ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; children: ReactNode }) { const entry = COMPARISONS.find((item) => item.slug === slug); @@ -142,26 +144,22 @@ export function CompareArticle({ slug, children }: { slug: ComparisonSlug; child const related = COMPARISONS.filter((item) => item.slug !== slug); return ( -

    -
    -
    -
    -
    - - {intro} -
    -
    +
    +
    +
    + + {intro}
    diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index b372fdef95..c0be461cd2 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -515,9 +515,10 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = assert.doesNotMatch(sections, /PageAiActions/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /kysely: "\/compare\/kysely\.md"/); - assert.match(article, /header className="bg-muted\/50"/); + assert.match(article, /pt-32 sm:pt-40/); + assert.match(article, /header className="mx-auto max-w-site px-6 sm:px-8"/); + assert.match(article, /rounded-2xl bg-muted\/50 px-6 py-10 sm:px-12 sm:py-14 lg:px-16/); assert.match(article, /overflow-x-clip/); - assert.doesNotMatch(article, /rounded-2xl bg-muted\/50/); assert.doesNotMatch(article, /w-screen|100vw/); const byline = readFileSync(join(compareDir, "byline.tsx"), "utf8"); assert.match(byline, /linkedin\.com\/in\/martjvr/); From aac7b38dd24a4ec8049b069f545f5b6e85deac90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:26:26 +0000 Subject: [PATCH 33/52] Compare: full-width disclosure and create-db hero CTA Span the Prisma-makes-Prisma-ORM line across the hero card so it reads as one line on desktop, and retarget the second ORM hero button at create-db. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 4 +- apps/site/src/app/compare/article.tsx | 23 ++++++++ .../site/src/app/compare/kysely/_sections.tsx | 55 ++++++++++--------- .../src/app/compare/mongoose/_sections.tsx | 55 ++++++++++--------- .../src/lib/markdown/comparisons.test.tsx | 10 +++- 5 files changed, 90 insertions(+), 57 deletions(-) diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index b4e03a0668..06a139c3c8 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -45,7 +45,7 @@ export const ORM_HERO_CTAS = { href: "/docs/prisma-orm/quickstart/postgresql", }, secondary: { - label: "Get started with Prisma Postgres", - href: "https://console.prisma.io", + label: "Create a database", + href: ORM_COMPARISON_CTA.secondaryCta.href, }, } as const; diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 211572e6a3..e6521ea8b7 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -5,6 +5,7 @@ import { PrismButton, PrismButtonOutline } from "@/components/brand/prism-button import { COMPARISON_CTA, ORM_COMPARISON_CTA, ORM_HERO_CTAS } from "./_content"; import { ArticleToc, ArticleTocDisclosure, type TocItem } from "./article-toc"; import { COMPARISONS, type ComparisonSlug } from "./comparisons"; +import { QUIET_LINK } from "./table-styles"; 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-[1.375rem] prose-h2:leading-[1.3] prose-h2:tracking-[-0.01em] prose-h3:mt-8 prose-h3:text-lg [&_:is(p,ul,ol):not(:is(td,th,blockquote)_*)]:max-w-[36rem] 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"; @@ -63,6 +64,28 @@ const TOC: Record = { ], }; +/** + * 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, +}: { + competitor: string; + checked: string; +}) { + return ( +

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

    + ); +} + /** 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 : COMPARISON_CTA.primaryCta; diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index c7bae4d9a3..b27986939b 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -14,7 +14,12 @@ import { } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; -import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { + CompareArticle, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; import { comparisonByline } from "../byline"; import { articleReadingStats } from "../reading-time"; import { CompareTable } from "../compare-table"; @@ -160,32 +165,28 @@ function buildKyselyPage(minutes: number) { 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 })} -

    - Prisma makes Prisma ORM. Kysely figures come from the pages listed under{" "} - - Sources - - , checked {CHECKED}. -

    +
    +
    +

    + {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 })}
    ,
    diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index e52fccf2c2..14c17d739f 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -14,7 +14,12 @@ import { } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; -import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { + CompareArticle, + comparisonClosingCta, + comparisonDisclosure, + comparisonHeroCtas, +} from "../article"; import { comparisonByline } from "../byline"; import { articleReadingStats } from "../reading-time"; import { CompareTable } from "../compare-table"; @@ -159,32 +164,28 @@ function buildMongoosePage(minutes: number) { 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 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 })} -

    - Prisma makes Prisma ORM. Mongoose figures come from the pages listed under{" "} - - Sources - - , checked {CHECKED}. -

    +
    +
    +

    + {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 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 })}
    ,
    diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index c0be461cd2..7ce5f2ed23 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -9,7 +9,7 @@ import { getMongooseArticleFields, getMongooseFacts } from "@/app/compare/mongoo import { getMongooseReadingStats } from "@/app/compare/mongoose/_sections"; import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; -import { COMPARISON_CTA, ORM_COMPARISON_CTA } from "@/app/compare/_content"; +import { COMPARISON_CTA, ORM_COMPARISON_CTA, ORM_HERO_CTAS } from "@/app/compare/_content"; import { CompareTable } from "@/app/compare/compare-table"; import { ComparisonPricingTable } from "@/app/compare/pricing-table"; import { TABLE_FRAME } from "@/app/compare/table-styles"; @@ -305,12 +305,20 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok( markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)"), ); + assert.ok(markdown.includes("[Create a database](https://console.prisma.io)")); assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); + assert.equal(ORM_HERO_CTAS.secondary.href, ORM_COMPARISON_CTA.secondaryCta.href); + assert.equal(ORM_HERO_CTAS.secondary.label, "Create a database"); + const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /comparisonDisclosure/); + assert.match(article, /w-full min-w-0/); + assert.doesNotMatch(article, /max-w-\[32rem\].*Prisma makes Prisma ORM/); const mongoosePage = getMarkdownPage("/compare/mongoose"); assert.ok(mongoosePage); const mongooseMarkdown = renderMarkdownDocument(mongoosePage); assert.ok(mongooseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); assert.ok(mongooseMarkdown.includes("Add the library")); + assert.ok(mongooseMarkdown.includes("[Create a database](https://console.prisma.io)")); assert.doesNotMatch(mongooseMarkdown, /Free to start, no credit card required/); const convexPage = getMarkdownPage("/compare/convex"); assert.ok(convexPage); From 62f0dd94141e7f727bfec7ccf48548c012cc4a7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:27:37 +0000 Subject: [PATCH 34/52] Compare: point Create a database at create-db.prisma.io/web Hero second CTA and the close supporting line go to the no-account create-db web UI, not the console or docs. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 5 +++- apps/site/src/app/compare/article.tsx | 4 ++-- .../src/lib/markdown/comparisons.test.tsx | 24 +++++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index 06a139c3c8..e31dad49e3 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -39,6 +39,9 @@ export const ORM_COMPARISON_CTA = { }, } as const; +/** Temporary Prisma Postgres in the browser, no account. */ +export const CREATE_DB_WEB = "https://create-db.prisma.io/web"; + export const ORM_HERO_CTAS = { primary: { label: "Get started with Prisma ORM", @@ -46,6 +49,6 @@ export const ORM_HERO_CTAS = { }, secondary: { label: "Create a database", - href: ORM_COMPARISON_CTA.secondaryCta.href, + href: CREATE_DB_WEB, }, } as const; diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index e6521ea8b7..00fa788e55 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -2,7 +2,7 @@ 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, ORM_COMPARISON_CTA, ORM_HERO_CTAS } from "./_content"; +import { COMPARISON_CTA, CREATE_DB_WEB, ORM_COMPARISON_CTA, ORM_HERO_CTAS } from "./_content"; import { ArticleToc, ArticleTocDisclosure, type TocItem } from "./article-toc"; import { COMPARISONS, type ComparisonSlug } from "./comparisons"; import { QUIET_LINK } from "./table-styles"; @@ -125,7 +125,7 @@ export function comparisonClosingCta(kind: "orm" | "postgres" = "postgres") {

    Need a database?{" "} Create one instantly diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 7ce5f2ed23..1a55b7110e 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -9,7 +9,12 @@ import { getMongooseArticleFields, getMongooseFacts } from "@/app/compare/mongoo import { getMongooseReadingStats } from "@/app/compare/mongoose/_sections"; import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; -import { COMPARISON_CTA, ORM_COMPARISON_CTA, ORM_HERO_CTAS } from "@/app/compare/_content"; +import { + COMPARISON_CTA, + CREATE_DB_WEB, + ORM_COMPARISON_CTA, + ORM_HERO_CTAS, +} from "@/app/compare/_content"; import { CompareTable } from "@/app/compare/compare-table"; import { ComparisonPricingTable } from "@/app/compare/pricing-table"; import { TABLE_FRAME } from "@/app/compare/table-styles"; @@ -297,7 +302,7 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok(markdown.includes("Read the ORM docs")); assert.match( markdown, - /Need a database\? \[Create one instantly\]\(https:\/\/console\.prisma\.io\)/, + /Need a database\? \[Create one instantly\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); assert.doesNotMatch(markdown, /Free to start, no credit card required/); assert.doesNotMatch(markdown, /Create a database and start building in minutes/); @@ -305,10 +310,15 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok( markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)"), ); - assert.ok(markdown.includes("[Create a database](https://console.prisma.io)")); + assert.ok(markdown.includes(`[Create a database](${CREATE_DB_WEB})`)); + assert.doesNotMatch( + markdown.slice(0, markdown.indexOf("## Add Prisma ORM to an existing project")), + /console\.prisma\.io/, + ); assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); - assert.equal(ORM_HERO_CTAS.secondary.href, ORM_COMPARISON_CTA.secondaryCta.href); + assert.equal(ORM_HERO_CTAS.secondary.href, CREATE_DB_WEB); assert.equal(ORM_HERO_CTAS.secondary.label, "Create a database"); + assert.doesNotMatch(CREATE_DB_WEB, /console\.prisma\.io|^\/docs/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /comparisonDisclosure/); assert.match(article, /w-full min-w-0/); @@ -318,7 +328,11 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () const mongooseMarkdown = renderMarkdownDocument(mongoosePage); assert.ok(mongooseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); assert.ok(mongooseMarkdown.includes("Add the library")); - assert.ok(mongooseMarkdown.includes("[Create a database](https://console.prisma.io)")); + assert.ok(mongooseMarkdown.includes(`[Create a database](${CREATE_DB_WEB})`)); + assert.match( + mongooseMarkdown, + /Need a database\? \[Create one instantly\]\(https:\/\/create-db\.prisma\.io\/web\)/, + ); assert.doesNotMatch(mongooseMarkdown, /Free to start, no credit card required/); const convexPage = getMarkdownPage("/compare/convex"); assert.ok(convexPage); From 72a3ff54a6cded6b882a296e419c2f70caa529a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:31:30 +0000 Subject: [PATCH 35/52] Compare: rename create-db CTA to Create an instant database Hero second button and the close supporting line use the same label, still pointing at create-db.prisma.io/web. Co-authored-by: rensburg --- apps/site/src/app/compare/_content.ts | 2 +- apps/site/src/app/compare/article.tsx | 2 +- apps/site/src/lib/markdown/comparisons.test.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/site/src/app/compare/_content.ts b/apps/site/src/app/compare/_content.ts index e31dad49e3..0c9d4547e6 100644 --- a/apps/site/src/app/compare/_content.ts +++ b/apps/site/src/app/compare/_content.ts @@ -48,7 +48,7 @@ export const ORM_HERO_CTAS = { href: "/docs/prisma-orm/quickstart/postgresql", }, secondary: { - label: "Create a database", + label: "Create an instant database", href: CREATE_DB_WEB, }, } as const; diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 00fa788e55..1e6fa13933 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -128,7 +128,7 @@ export function comparisonClosingCta(kind: "orm" | "postgres" = "postgres") { href={CREATE_DB_WEB} className="underline underline-offset-4 decoration-black/35 transition-colors hover:text-foreground hover:decoration-black/50" > - Create one instantly + Create an instant database .

    diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 1a55b7110e..88c2b183b2 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -302,7 +302,7 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok(markdown.includes("Read the ORM docs")); assert.match( markdown, - /Need a database\? \[Create one instantly\]\(https:\/\/create-db\.prisma\.io\/web\)/, + /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); assert.doesNotMatch(markdown, /Free to start, no credit card required/); assert.doesNotMatch(markdown, /Create a database and start building in minutes/); @@ -310,14 +310,14 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok( markdown.includes("[Get started with Prisma ORM](/docs/prisma-orm/quickstart/postgresql)"), ); - assert.ok(markdown.includes(`[Create a database](${CREATE_DB_WEB})`)); + assert.ok(markdown.includes(`[Create an instant database](${CREATE_DB_WEB})`)); assert.doesNotMatch( markdown.slice(0, markdown.indexOf("## Add Prisma ORM to an existing project")), /console\.prisma\.io/, ); assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); assert.equal(ORM_HERO_CTAS.secondary.href, CREATE_DB_WEB); - assert.equal(ORM_HERO_CTAS.secondary.label, "Create a database"); + assert.equal(ORM_HERO_CTAS.secondary.label, "Create an instant database"); assert.doesNotMatch(CREATE_DB_WEB, /console\.prisma\.io|^\/docs/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /comparisonDisclosure/); @@ -328,10 +328,10 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () const mongooseMarkdown = renderMarkdownDocument(mongoosePage); assert.ok(mongooseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); assert.ok(mongooseMarkdown.includes("Add the library")); - assert.ok(mongooseMarkdown.includes(`[Create a database](${CREATE_DB_WEB})`)); + assert.ok(mongooseMarkdown.includes(`[Create an instant database](${CREATE_DB_WEB})`)); assert.match( mongooseMarkdown, - /Need a database\? \[Create one instantly\]\(https:\/\/create-db\.prisma\.io\/web\)/, + /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); assert.doesNotMatch(mongooseMarkdown, /Free to start, no credit card required/); const convexPage = getMarkdownPage("/compare/convex"); From 05e9c0ec9dda6a48c98a617e1b69db1bfdcd089c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 21:44:21 +0000 Subject: [PATCH 36/52] Compare: match Codex measure, split quote cards, compress TOC Copy the Codex guide's 760px column, 220px rail, and hero type (max-w-4xl title, max-w-3xl dek). Render the two quotes as separate cards. Put copy+AI above On this page, and drop nav overflow so the cluster plus the full list fit a 900px window. Co-authored-by: rensburg --- apps/site/src/app/compare/article-toc.tsx | 35 ++++----- apps/site/src/app/compare/article.tsx | 74 +++++++++---------- .../site/src/app/compare/kysely/_sections.tsx | 68 +++++++---------- .../src/app/compare/mongoose/_sections.tsx | 68 +++++++---------- apps/site/src/app/compare/page-ai-actions.tsx | 2 +- apps/site/src/app/compare/proof-cards.tsx | 41 ++++++++++ .../src/lib/markdown/comparisons.test.tsx | 25 +++++++ 7 files changed, 177 insertions(+), 136 deletions(-) create mode 100644 apps/site/src/app/compare/proof-cards.tsx diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index 85b2bfcc71..65d94be28c 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -98,14 +98,14 @@ function TocList({ onChoose: (id: string) => void; }) { return ( -
      +
        {items.map((item) => (
      • onChoose(item.id)} - className="-ml-0.5 flex min-h-11 items-center border-l-2 border-transparent py-2.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" + className="-ml-0.5 flex min-h-11 items-center border-l-2 border-transparent py-1 pl-2.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" > {item.label} @@ -115,7 +115,11 @@ function TocList({ ); } -/** The sticky right-hand list, matching the docs' table of contents. */ +/** + * The sticky right-hand list. No overflow scroll: the AI cluster plus every + * item must fit a 900px-tall window under `top-28`. Rows stay 44px (`min-h-11`) + * unless that overflows; then shrink the tap row rather than scrolling the rail. + */ export function ArticleToc({ items, markdownPath, @@ -125,17 +129,14 @@ export function ArticleToc({ }) { const [active, choose] = useActiveItem(items); return ( -
    -
    -
    -
    -
    - -
    -
    {body}
    - {closing} - {footnote} - +
    +
    +
    +
    - +
    {body}
    + {closing} + {footnote} +
    +
    ); diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index b27986939b..334fe3df44 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -21,6 +21,7 @@ import { 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"; @@ -166,26 +167,24 @@ function buildKyselyPage(minutes: number) { 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 })} -
    +

    + {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 })}
    ,
    @@ -496,26 +495,15 @@ function buildKyselyPage(minutes: number) { What do teams say about Prisma ORM?

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

    -
    - {PROOF.map((item) => ( -
    - -
    - “{item.quote}” -
    -
    - {item.name}, {item.role},{" "} - {item.company} -
    -
    - ))} -
    + {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )}
    diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index 14c17d739f..532c2dbfa6 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -21,6 +21,7 @@ import { 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"; @@ -165,26 +166,24 @@ function buildMongoosePage(minutes: number) { 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 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 })} -
    +

    + {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 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 })}
    ,
    @@ -502,26 +501,15 @@ function buildMongoosePage(minutes: number) { What do teams say about Prisma ORM?

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

    -
    - {PROOF.map((item) => ( -
    - -
    - “{item.quote}” -
    -
    - {item.name}, {item.role},{" "} - {item.company} -
    -
    - ))} -
    + {comparisonProofCards( + PROOF.map((item) => ({ + name: item.name, + role: item.role, + company: item.company, + quote: item.quote, + logo: LOGOS[item.company], + })), + )}
    diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index f30a26503f..131e7502b3 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -183,7 +183,7 @@ export function PageAiActions({ markdownPath }: { markdownPath: string }) {
    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 0000000000..3d6270d0d0 --- /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/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 88c2b183b2..0067801533 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -511,6 +511,8 @@ test("kysely code lines pad past the Copy pill and preload the unbracketed mono assert.doesNotMatch(page, /preload:\s*false/); const toc = readFileSync(join(compareDir, "article-toc.tsx"), "utf8"); assert.match(toc, /min-h-11 items-center/); + assert.doesNotMatch(toc, /overflow-y-auto/); + assert.doesNotMatch(toc, /max-h-\[calc/); const heading = readFileSync(join(compareDir, "anchor-heading.tsx"), "utf8"); assert.match(heading, /tabIndex=\{inTabOrder \? 0 : -1\}/); assert.match(heading, /COPIED_VISIBLE_MS/); @@ -540,8 +542,12 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = assert.match(article, /pt-32 sm:pt-40/); assert.match(article, /header className="mx-auto max-w-site px-6 sm:px-8"/); assert.match(article, /rounded-2xl bg-muted\/50 px-6 py-10 sm:px-12 sm:py-14 lg:px-16/); + assert.match(article, /lg:grid-cols-\[minmax\(0,760px\)_220px\]/); + assert.match(article, /min-w-0 max-w-3xl/); assert.match(article, /overflow-x-clip/); assert.doesNotMatch(article, /w-screen|100vw/); + assert.doesNotMatch(article, /max-w-\[54rem\]/); + assert.doesNotMatch(article, /lg:grid-cols-\[minmax\(0,1fr\)_11rem\]/); const byline = readFileSync(join(compareDir, "byline.tsx"), "utf8"); assert.match(byline, /linkedin\.com\/in\/martjvr/); assert.match(byline, /x\.com\/vanrensbird/); @@ -550,6 +556,20 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = const toc = readFileSync(join(compareDir, "article-toc.tsx"), "utf8"); assert.match(toc, /PageAiActions/); assert.match(toc, /markdownPath/); + const desktopToc = toc.slice( + toc.indexOf("export function ArticleToc"), + toc.indexOf("export function ArticleTocDisclosure"), + ); + assert.ok(desktopToc.indexOf("PageAiActions") < desktopToc.indexOf(">On this page

    ")); + const disclosureToc = toc.slice(toc.indexOf("export function ArticleTocDisclosure")); + assert.ok(disclosureToc.indexOf("PageAiActions") < disclosureToc.indexOf("Open with AI Date: Sun, 18 Oct 2026 22:01:00 +0000 Subject: [PATCH 37/52] Compare: restore TOC spacing and center the reading unit Revert the compressed rail type, padding, and gaps; keep copy+AI above On this page. Center the hero, 760px column, and 220px rail as one block like the serverless-postgres blog post so the layout is not hugged left. Co-authored-by: rensburg --- apps/site/src/app/compare/article-toc.tsx | 24 ++-- apps/site/src/app/compare/article.tsx | 109 +++++++++--------- apps/site/src/app/compare/page-ai-actions.tsx | 2 +- .../src/lib/markdown/comparisons.test.tsx | 14 ++- 4 files changed, 81 insertions(+), 68 deletions(-) diff --git a/apps/site/src/app/compare/article-toc.tsx b/apps/site/src/app/compare/article-toc.tsx index 65d94be28c..1f508124a5 100644 --- a/apps/site/src/app/compare/article-toc.tsx +++ b/apps/site/src/app/compare/article-toc.tsx @@ -98,14 +98,14 @@ function TocList({ onChoose: (id: string) => void; }) { return ( -
      +
        {items.map((item) => (
      • onChoose(item.id)} - className="-ml-0.5 flex min-h-11 items-center border-l-2 border-transparent py-1 pl-2.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" + className="-ml-0.5 flex min-h-11 items-center border-l-2 border-transparent py-2.5 pl-3.5 transition-colors duration-200 hover:text-foreground motion-reduce:transition-none aria-[current=location]:border-foreground aria-[current=location]:font-medium aria-[current=location]:text-foreground" > {item.label} @@ -116,9 +116,8 @@ function TocList({ } /** - * The sticky right-hand list. No overflow scroll: the AI cluster plus every - * item must fit a 900px-tall window under `top-28`. Rows stay 44px (`min-h-11`) - * unless that overflows; then shrink the tap row rather than scrolling the rail. + * 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, @@ -129,13 +128,16 @@ export function ArticleToc({ }) { const [active, choose] = useActiveItem(items); return ( -

    {cta.body}

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

    + {ORM_COMPARISON_CTA.body} +

    + ) : null}
      {cta.checks.map((item) => (
    • {item.label}
    • ))}
    - {kind === "orm" ? ( -

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

    - ) : null} +

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

    {cta.primaryCta.label} 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 0000000000..27bfdbc2a0 --- /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 2b9892a5b8..67f6b3bebe 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 c0d82ab9f1..7bbbe47f3f 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { Info } from "lucide-react"; import { Accordion, AccordionContent, @@ -7,7 +6,19 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { TESTIMONIALS } from "@/components/sections/testimonials-data"; -import { CompareArticle, comparisonClosingCta, comparisonHeroCtas } from "../article"; +import { AnchorHeading } from "../anchor-heading"; +import { + CompareArticle, + 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, @@ -15,22 +26,89 @@ 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", ); +const LOGOS: Record = { + Bucket: { src: "/logos/customers/bucket.png", width: 108 }, + "Cal.com": { src: "/logos/companies/cal.svg", width: 96 }, +}; + +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. */ -export function getConvexPageContent() { +function buildConvexPage(minutes: number) { return [

    @@ -49,158 +127,79 @@ export function getConvexPageContent() { Prisma Compute {" "} - hosts the app. Pick{" "} + 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. Prisma makes Prisma Postgres, Prisma ORM, and Prisma - Compute. All{" "} + host such as Vercel or Netlify. All{" "} Prisma comparisons {" "} are listed in one place.

    - -

    - - Read the Postgres docs - {" "} - - See which fits - -

    - {comparisonHeroCtas()} + {comparisonHeroCtas("postgres")} + {comparisonByline({ author: CONVEX_AUTHOR, updated: CONVEX_UPDATED, minutes })} + {comparisonDisclosure({ + competitor: "Convex", + checked: CHECKED, + prismaProduct: "Prisma Postgres", + })}

    , -
    -
    -
    -
    -

    - Choose Prisma Postgres if -

    -
    +

    + 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. +

    @@ -278,9 +297,10 @@ function buildMongoosePage(minutes: number) { How do Prisma ORM and Mongoose compare feature by feature?

    - Mongoose models MongoDB documents in application code. Prisma ORM is a contract-based ORM. - On MongoDB the PostgreSQL APIs (SQL query builder, relation filters) are absent. Columns - are in alphabetical order. + 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.

    Mongoose's MongoDB support next to the two Prisma ORM lines. Prisma ORM 7 is the - generally available release. No means no support. + 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>.

    ) => { trackConsoleCta(href, children, ctaLocation); @@ -161,11 +168,15 @@ export function PrismButtonOutline({ type = "button", ctaLocation, size = "default", + target, + rel, }: PrismButtonProps) { const Comp = href ? "a" : "button"; return ( ) => { trackConsoleCta(href, children, ctaLocation); diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 89451ca549..6a030522bf 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -14,6 +14,7 @@ import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; import { COMPARISON_CTA, + CREATE_DB_NEW_TAB, CREATE_DB_WEB, ORM_COMPARISON_CTA, ORM_HERO_CTAS, @@ -322,11 +323,20 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () assert.ok(markdown.includes("[Get started with Prisma Postgres](https://console.prisma.io)")); assert.equal(ORM_HERO_CTAS.secondary.href, CREATE_DB_WEB); assert.equal(ORM_HERO_CTAS.secondary.label, "Create an instant database"); + assert.equal(CREATE_DB_NEW_TAB.target, "_blank"); + assert.equal(CREATE_DB_NEW_TAB.rel, "noopener noreferrer"); assert.doesNotMatch(CREATE_DB_WEB, /console\.prisma\.io|^\/docs/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /comparisonDisclosure/); assert.match(article, /w-full min-w-0/); + assert.match(article, /CREATE_DB_NEW_TAB/); assert.doesNotMatch(article, /max-w-\[32rem\].*Prisma makes Prisma ORM/); + const prismButton = readFileSync( + join(compareDir, "../../components/brand/prism-button.tsx"), + "utf8", + ); + assert.match(prismButton, /target\?: React\.HTMLAttributeAnchorTarget/); + assert.match(prismButton, /target=\{href \? target : undefined\}/); const mongoosePage = getMarkdownPage("/compare/mongoose"); assert.ok(mongoosePage); const mongooseMarkdown = renderMarkdownDocument(mongoosePage); @@ -705,7 +715,7 @@ test("mongoose Markdown renders the remaining cards and drops the fit columns", const page = getMarkdownPage("/compare/mongoose"); assert.ok(page); const markdown = renderMarkdownDocument(page); - assert.equal(mongoose.APART.length, 2); + assert.equal(mongoose.APART.length, 4); for (const card of mongoose.APART) { assert.ok(markdown.includes(`\n### ${card.title}\n`), `missing card heading: ${card.title}`); } @@ -715,6 +725,28 @@ test("mongoose Markdown renders the remaining cards and drops the fit columns", assert.doesNotMatch(markdown, /choose-mongoose/); }); +test("mongoose Markdown cites the MongoDB Prisma Next post, /mongodb, and MongoDB without compromise in the body and Sources", () => { + const page = getMarkdownPage("/compare/mongoose"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const sourcesAt = markdown.indexOf("\n## Sources\n"); + assert.ok(sourcesAt > 0, "missing Sources heading"); + const body = markdown.slice(0, sourcesAt); + const sources = markdown.slice(sourcesAt); + const cites = [ + mongoose.MONGO_BLOG_PRISMA_NEXT, + mongoose.PRISMA_MONGODB_PAGE, + mongoose.PRISMA_MONGODB_BLOG, + ]; + for (const href of cites) { + assert.ok(body.includes(`](${href})`), `body missing ${href}`); + assert.ok(sources.includes(`](${href})`), `Sources missing ${href}`); + } + assert.match(body, /Driver team named this as the top missing feature/); + assert.match(body, /findMany/); + assert.match(body, /data migrations were still coming/); +}); + test("mongoose Markdown carries pricing as a table followed by the free tier note", () => { const page = getMarkdownPage("/compare/mongoose"); assert.ok(page); From 5778c6b089f926c7ea4532f47acc100420c40add Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 06:26:06 +0000 Subject: [PATCH 42/52] Compare: update Mongoose heading assertion for the migration card Co-authored-by: rensburg --- apps/site/src/lib/markdown/comparisons.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 6a030522bf..06d55cbe0a 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -671,7 +671,7 @@ test("mongoose Markdown keeps yes and no marks out of the feature table, and anc assert.doesNotMatch(hosted, /\b(Yes|No)\b/); assert.ok(markdown.includes("\n## When should I choose Mongoose or Prisma ORM?\n")); assert.ok(markdown.includes("\n### The same ORM client on PostgreSQL\n")); - assert.ok(markdown.includes("\n### A migration plan that does not need the database\n")); + assert.ok(markdown.includes("\n### Migrations for indexes and validators\n")); assert.doesNotMatch(markdown, /Copy link|Copied/); }); From 70a5bb2d5145635b4526da5d988f3fe7fd607a4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 06:37:05 +0000 Subject: [PATCH 43/52] Compare: render the choose section as a stacked table The first When should I choose section uses CompareTable across Kysely, Mongoose, and Convex: one row per contrast, competitor | Prisma, with the same stacked-card phone layout as Features. Co-authored-by: rensburg --- apps/site/src/app/compare/article.tsx | 31 ++++++++++++++++ apps/site/src/app/compare/compare-table.tsx | 36 +++++++++++-------- .../site/src/app/compare/convex/_sections.tsx | 25 ++++--------- .../site/src/app/compare/kysely/_sections.tsx | 26 ++++---------- .../src/app/compare/mongoose/_sections.tsx | 26 ++++---------- .../src/lib/markdown/comparison-content.ts | 11 ++++-- .../src/lib/markdown/comparisons.test.tsx | 33 +++++++++++++++-- 7 files changed, 113 insertions(+), 75 deletions(-) diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 0f31b49f61..90d8b60955 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -12,7 +12,9 @@ import { } 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"; @@ -97,6 +99,35 @@ export function comparisonDisclosure({ ); } +/** + * 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; diff --git a/apps/site/src/app/compare/compare-table.tsx b/apps/site/src/app/compare/compare-table.tsx index 4de82d01d7..c8a5f5a70b 100644 --- a/apps/site/src/app/compare/compare-table.tsx +++ b/apps/site/src/app/compare/compare-table.tsx @@ -12,27 +12,29 @@ import { import { TableScrollRegion } from "./table-scroll-region"; export type CompareTableRow = { - label: string; + /** 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. */ - corner: string; + /** Header over the row labels. Omit with unlabelled choose rows. */ + corner?: string; columns: readonly string[]; rows: readonly CompareTableRow[]; minWidth: string; }; /** - * Feature, database, and task 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. + * 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 columnWidth = `${78 / columns.length}%`; + const labelled = Boolean(corner); + const columnWidth = `${(labelled ? 78 : 100) / columns.length}%`; return ( - - {corner} - + {labelled ? ( + + {corner} + + ) : null} {columns.map((column) => ( {column} @@ -56,11 +60,13 @@ export function CompareTable({ labelledBy, corner, columns, rows, minWidth }: Co - {rows.map((row) => ( - - - {row.label} - + {rows.map((row, rowIndex) => ( + + {labelled ? ( + + {row.label} + + ) : null} {row.cells.map((cell, index) => ( {cell} diff --git a/apps/site/src/app/compare/convex/_sections.tsx b/apps/site/src/app/compare/convex/_sections.tsx index 7bbbe47f3f..fee39f146f 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -9,6 +9,7 @@ import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; import { CompareArticle, + comparisonChooseTable, comparisonClosingCta, comparisonDisclosure, comparisonHeroCtas, @@ -151,24 +152,12 @@ function buildConvexPage(minutes: number) { When should I choose Convex or Prisma Postgres? -
    -
    -

    Convex

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

    Prisma Postgres

    -
      - {CHOOSE_PRISMA.map((item) => ( -
    • {renderFaqAnswer(item)}
    • - ))} -
    -
    -
    + {comparisonChooseTable({ + competitor: "Convex", + prisma: "Prisma Postgres", + competitorItems: CHOOSE_CONVEX, + prismaItems: CHOOSE_PRISMA, + })}
    diff --git a/apps/site/src/app/compare/kysely/_sections.tsx b/apps/site/src/app/compare/kysely/_sections.tsx index 334fe3df44..865744bab2 100644 --- a/apps/site/src/app/compare/kysely/_sections.tsx +++ b/apps/site/src/app/compare/kysely/_sections.tsx @@ -16,6 +16,7 @@ import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; import { CompareArticle, + comparisonChooseTable, comparisonClosingCta, comparisonDisclosure, comparisonHeroCtas, @@ -67,7 +68,6 @@ const BODY = 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"; /** * Full support reads as body text; preview and ORM 7 labels are muted; No is @@ -192,24 +192,12 @@ function buildKyselyPage(minutes: number) { When should I choose Kysely or Prisma ORM? -
    -
    -

    Kysely

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

    Prisma ORM

    -
      - {CHOOSE_PRISMA.map((item) => ( -
    • {renderFaqAnswer(item)}
    • - ))} -
    -
    -
    + {comparisonChooseTable({ + competitor: "Kysely", + prisma: "Prisma ORM", + competitorItems: CHOOSE_KYSELY, + prismaItems: CHOOSE_PRISMA, + })}
    diff --git a/apps/site/src/app/compare/mongoose/_sections.tsx b/apps/site/src/app/compare/mongoose/_sections.tsx index 39cb99c9ba..3a3996f74a 100644 --- a/apps/site/src/app/compare/mongoose/_sections.tsx +++ b/apps/site/src/app/compare/mongoose/_sections.tsx @@ -16,6 +16,7 @@ import { TESTIMONIALS } from "@/components/sections/testimonials-data"; import { AnchorHeading } from "../anchor-heading"; import { CompareArticle, + comparisonChooseTable, comparisonClosingCta, comparisonDisclosure, comparisonHeroCtas, @@ -70,7 +71,6 @@ const BODY = 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"; /** * Full support reads as body text; preview and ORM 7 labels are muted; No is @@ -201,24 +201,12 @@ function buildMongoosePage(minutes: number) { When should I choose Mongoose or Prisma ORM? -
    -
    -

    Mongoose

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

    Prisma ORM

    -
      - {CHOOSE_PRISMA.map((item) => ( -
    • {renderFaqAnswer(item)}
    • - ))} -
    -
    -
    + {comparisonChooseTable({ + competitor: "Mongoose", + prisma: "Prisma ORM", + competitorItems: CHOOSE_MONGOOSE, + prismaItems: CHOOSE_PRISMA, + })}

    MongoDB's May 2026 post{" "} diff --git a/apps/site/src/lib/markdown/comparison-content.ts b/apps/site/src/lib/markdown/comparison-content.ts index d7c0a2eb21..6053763082 100644 --- a/apps/site/src/lib/markdown/comparison-content.ts +++ b/apps/site/src/lib/markdown/comparison-content.ts @@ -101,9 +101,16 @@ export function renderComparisonContent(node: ReactNode, path: string): string { if (type === CompareTable) { const { corner, columns, rows } = props as unknown as CompareTableProps; const cell = (node: ReactNode) => render(node).trim().replace(/\s+/g, " "); + const labelled = Boolean(corner); return `\n\n${table( - [escapeText(corner), ...columns.map((column) => escapeText(column))], - rows.map((row) => [escapeText(row.label), ...row.cells.map(cell)]), + [ + ...(labelled ? [escapeText(corner ?? "")] : []), + ...columns.map((column) => escapeText(column)), + ], + rows.map((row) => [ + ...(labelled ? [escapeText(row.label ?? "")] : []), + ...row.cells.map(cell), + ]), )}\n\n`; } diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 06d55cbe0a..6f5c6736ef 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -463,6 +463,21 @@ test("stacked comparison tables label every cell for the phone card layout", () }); assert.deepEqual(dataLabels(feature), ["Kysely", "Prisma ORM"]); + const choose = CompareTable({ + labelledBy: "choose", + columns: ["Kysely", "Prisma ORM"], + minWidth: "40rem", + rows: [ + { + cells: [ + "You want the library itself to be the SQL query builder.", + "You want a contract file.", + ], + }, + ], + }); + assert.deepEqual(dataLabels(choose), ["Kysely", "Prisma ORM"]); + const pricing = ComparisonPricingTable({ labelledBy: "pricing", corner: "Cost", @@ -567,7 +582,8 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = markdown.indexOf("## When should I choose Kysely or Prisma ORM?"), markdown.indexOf("## What does the same query look like in each?"), ); - assert.match(choose, /### Kysely/); + assert.match(choose, /\| Kysely \| Prisma ORM \|/); + assert.doesNotMatch(choose, /### Kysely/); assert.doesNotMatch(choose, /A contract is the/); assert.match(choose, /a contract is the `contract\.prisma` file that describes your models/); assert.match(choose, /dialect — Kysely's adapter for one database/); @@ -576,7 +592,10 @@ test("kysely choose leads with the lists and defines terms in the bullets", () = assert.match(markdown, /email filter/); const sections = readFileSync(join(compareDir, "kysely/_sections.tsx"), "utf8"); assert.doesNotMatch(sections, /PageAiActions/); + assert.match(sections, /comparisonChooseTable/); + assert.doesNotMatch(sections, /grid gap-10 sm:grid-cols-2/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /function comparisonChooseTable/); assert.match(article, /kysely: "\/compare\/kysely\.md"/); assert.match(article, /pt-32 sm:pt-40/); assert.match(article, /header className="mx-auto max-w-site px-6 sm:px-8"/); @@ -873,13 +892,16 @@ test("mongoose choose leads with the lists and Open-with-AI points at mongoose.m markdown.indexOf("## When should I choose Mongoose or Prisma ORM?"), markdown.indexOf("## What does the same query look like in each?"), ); - assert.match(choose, /### Mongoose/); + assert.match(choose, /\| Mongoose \| Prisma ORM \|/); + assert.doesNotMatch(choose, /### Mongoose/); assert.match(choose, /a contract is the file that describes your collections or tables/); assert.match(markdown, /author include/); assert.match(markdown, /published filter/); const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /mongoose: "\/compare\/mongoose\.md"/); const sections = readFileSync(join(compareDir, "mongoose/_sections.tsx"), "utf8"); + assert.match(sections, /comparisonChooseTable/); + assert.doesNotMatch(sections, /grid gap-10 sm:grid-cols-2/); assert.match(sections, /max-w-4xl text-balance font-heading/); assert.match(sections, /mt-6 max-w-3xl text-pretty/); assert.doesNotMatch(sections, /max-w-2xl/); @@ -912,6 +934,12 @@ test("Convex Markdown carries the byline, disclosure, and Sources on the shared assert.ok(markdown.includes("Prisma makes Prisma Postgres")); assert.ok(markdown.includes("\n## Sources\n")); assert.ok(markdown.includes("\n## When should I choose Convex or Prisma Postgres?\n")); + const choose = markdown.slice( + markdown.indexOf("## When should I choose Convex or Prisma Postgres?"), + markdown.indexOf("## How do Convex and Prisma Postgres compare?"), + ); + assert.match(choose, /^\| Convex \| Prisma Postgres \|$/m); + assert.doesNotMatch(choose, /### Convex/); assert.ok(markdown.includes("\n## How do Convex and Prisma Postgres compare?\n")); assert.ok(markdown.includes("\n## Where does each one fit?\n")); assert.ok(markdown.includes("\n## How much do Convex and Prisma Postgres cost?\n")); @@ -923,6 +951,7 @@ test("Convex Markdown carries the byline, disclosure, and Sources on the shared const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); assert.match(article, /convex: "\/compare\/convex\.md"/); const sections = readFileSync(join(compareDir, "convex/_sections.tsx"), "utf8"); + assert.match(sections, /comparisonChooseTable/); assert.match(sections, /max-w-4xl text-balance font-heading/); assert.match(sections, /mt-6 max-w-3xl text-pretty/); assert.match(sections, /comparisonProofCards/); From 3866ee23eaacc097e0ef8960e2b10ae050e0d4dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:01:49 +0000 Subject: [PATCH 44/52] Compare: drop the directory plug from the Convex hero dek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kysely and Mongoose never had the line. Tests now reject “listed in one place” on every compare page so it cannot come back as a substitute plug. Co-authored-by: rensburg --- apps/site/src/app/compare/convex/_sections.tsx | 6 +----- apps/site/src/lib/markdown/comparisons.test.tsx | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/site/src/app/compare/convex/_sections.tsx b/apps/site/src/app/compare/convex/_sections.tsx index fee39f146f..88855de0b1 100644 --- a/apps/site/src/app/compare/convex/_sections.tsx +++ b/apps/site/src/app/compare/convex/_sections.tsx @@ -133,11 +133,7 @@ function buildConvexPage(minutes: number) { Convex {" "} when you want a reactive database and TypeScript query functions, with the frontend on a - host such as Vercel or Netlify. All{" "} - - Prisma comparisons - {" "} - are listed in one place. + host such as Vercel or Netlify.

    {comparisonHeroCtas("postgres")} {comparisonByline({ author: CONVEX_AUTHOR, updated: CONVEX_UPDATED, minutes })} diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 6f5c6736ef..8e8d1c4690 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -70,6 +70,9 @@ for (const entry of COMPARISONS) { assert.match(markdown, /\n## (Pricing|How much do .+ cost\?)\n/); assert.match(markdown, /\n## (What teams on Prisma say|What do teams say about .+\?)\n/); assert.match(markdown, /\n## (Move from|How do I switch from) /); + assert.doesNotMatch(markdown, /listed in one place/); + const sections = readFileSync(join(compareDir, `${entry.slug}/_sections.tsx`), "utf8"); + assert.doesNotMatch(sections, /listed in one place/); }); test(`${entry.slug} article schema agrees with the page metadata and directory`, () => { From ffc7fa7cfc7f998de81a81fb3e257b89b657494e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:09:46 +0000 Subject: [PATCH 45/52] Compare: add Prisma Postgres vs Neon on the shared chrome Head-to-head at /compare/neon for managed PostgreSQL: optional Prisma ORM and Prisma Compute in one project, or Neon Functions, Auth, Object Storage, and AI Gateway with the UI elsewhere. Pricing checked against neon.com/pricing on 19 October 2026 (Launch $0.106/CU-hour, Scale $0.222, storage $0.35). Co-authored-by: rensburg --- .../index.mdx | 2 +- .../switch-to-prisma-postgres/from-neon.mdx | 2 +- apps/site/src/app/compare/article.tsx | 13 + apps/site/src/app/compare/comparisons.ts | 8 + apps/site/src/app/compare/neon.json/route.ts | 11 + apps/site/src/app/compare/neon/_content.ts | 250 ++++++++++ apps/site/src/app/compare/neon/_sections.tsx | 426 ++++++++++++++++++ apps/site/src/app/compare/neon/facts.ts | 79 ++++ apps/site/src/app/compare/neon/page.tsx | 59 +++ apps/site/src/app/compare/page-ai-actions.tsx | 20 +- .../components/sections/compare-links-data.ts | 6 + apps/site/src/lib/comparison-metadata.ts | 2 +- .../src/lib/markdown/comparisons.test.tsx | 120 ++++- apps/site/src/lib/markdown/comparisons.tsx | 7 +- .../src/lib/markdown/markdown-parity.test.ts | 2 + 15 files changed, 995 insertions(+), 12 deletions(-) create mode 100644 apps/site/src/app/compare/neon.json/route.ts create mode 100644 apps/site/src/app/compare/neon/_content.ts create mode 100644 apps/site/src/app/compare/neon/_sections.tsx create mode 100644 apps/site/src/app/compare/neon/facts.ts create mode 100644 apps/site/src/app/compare/neon/page.tsx 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 265d75a27b..033e46f8ad 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 246aec3955..20a17d412c 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/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 90d8b60955..56ba935e93 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -30,6 +30,7 @@ const AI_MARKDOWN: Partial> = { kysely: "/compare/kysely.md", mongoose: "/compare/mongoose.md", convex: "/compare/convex.md", + neon: "/compare/neon.md", }; const TOC: Record = { @@ -73,6 +74,18 @@ const TOC: Record = { { 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" }, + ], }; /** diff --git a/apps/site/src/app/compare/comparisons.ts b/apps/site/src/app/compare/comparisons.ts index 35c3e0f77f..ece5feeed9 100644 --- a/apps/site/src/app/compare/comparisons.ts +++ b/apps/site/src/app/compare/comparisons.ts @@ -1,6 +1,7 @@ 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"; export const COMPARISON_INDEX = { path: "/compare", @@ -60,6 +61,13 @@ export const COMPARISONS = [ title: MONGOOSE_TITLE, description: MONGOOSE_DESCRIPTION, }, + { + slug: "neon", + category: "postgres", + competitor: "Neon", + title: NEON_TITLE, + description: NEON_DESCRIPTION, + }, ] as const satisfies readonly ComparisonEntry[]; export type ComparisonSlug = (typeof COMPARISONS)[number]["slug"]; 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 0000000000..df1f6d0f08 --- /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 0000000000..bb64a65556 --- /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 0000000000..6815e3335b --- /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 0000000000..0a9f37397b --- /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 0000000000..190fd4eedb --- /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 index ee998062a6..bb154746ea 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -37,13 +37,23 @@ const CONVEX_PROMPT = `I am choosing between Prisma Postgres and Convex for a Ty 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 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/convex.md") - ? CONVEX_PROMPT - : pageMdUrl.includes("/compare/mongoose.md") - ? MONGOOSE_PROMPT - : KYSELY_PROMPT; + const body = 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} diff --git a/apps/site/src/components/sections/compare-links-data.ts b/apps/site/src/components/sections/compare-links-data.ts index c898348dbc..3c15efaf8e 100644 --- a/apps/site/src/components/sections/compare-links-data.ts +++ b/apps/site/src/components/sections/compare-links-data.ts @@ -41,6 +41,12 @@ export const COMPARE_PRISMA_LINKS: CompareLink[] = [ href: "/blog/choosing-hosting-and-postgres-after-your-first-100-users", description: "What to run the app and the database on once real users arrive.", }, + { + label: "Prisma Postgres vs Neon", + href: "/compare/neon", + description: + "Managed PostgreSQL compared: optional Prisma ORM and Prisma Compute in one project, or Neon Functions, Auth, Object Storage, and AI Gateway.", + }, { label: "Neon vs Prisma Postgres for hosting and the database in one place", href: "/blog/neon-vs-prisma-postgres-app-hosting-in-one-place", diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index 74fd764528..c265f28c48 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -17,7 +17,7 @@ export function getComparisonMetadata(slug: ComparisonSlug) { const types: Record = { "text/markdown": new URL(`${page.path}.md`, baseUrl).toString(), }; - if (slug === "kysely" || slug === "mongoose" || slug === "convex") { + if (slug === "kysely" || slug === "mongoose" || slug === "convex" || slug === "neon") { types["application/json"] = new URL(`${page.path}.json`, baseUrl).toString(); } return { diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index 8e8d1c4690..b62a94db24 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -10,6 +10,10 @@ import { getKyselyReadingStats } from "@/app/compare/kysely/_sections"; import * as mongoose from "@/app/compare/mongoose/_content"; import { getMongooseArticleFields, getMongooseFacts } from "@/app/compare/mongoose/facts"; import { getMongooseReadingStats } from "@/app/compare/mongoose/_sections"; +import * as neon from "@/app/compare/neon/_content"; +import { getNeonArticleFields, getNeonFacts } from "@/app/compare/neon/facts"; +import { GET as getNeonFactsResponse } from "@/app/compare/neon.json/route"; +import { getNeonReadingStats } from "@/app/compare/neon/_sections"; import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; import { @@ -49,7 +53,7 @@ function plainMarkdown(text: string): string { for (const entry of COMPARISONS) { test(`${entry.slug} Markdown preserves comparison cells and every FAQ answer`, () => { - const content = { convex, kysely, mongoose }[entry.slug]; + const content = { convex, kysely, mongoose, neon }[entry.slug]; const page = getMarkdownPage(`/compare/${entry.slug}`); assert.ok(page); const markdown = renderMarkdownDocument(page); @@ -361,6 +365,16 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () convexMarkdown, /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); + const neonPage = getMarkdownPage("/compare/neon"); + assert.ok(neonPage); + const neonMarkdown = renderMarkdownDocument(neonPage); + assert.ok(neonMarkdown.includes("Free to start, no credit card required.")); + assert.ok(neonMarkdown.includes("Trusted by 500K+ developers globally")); + assert.ok(neonMarkdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.match( + neonMarkdown, + /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, + ); assert.equal(POSTGRES_HERO_CTAS.secondary.href, CREATE_DB_WEB); assert.equal(POSTGRES_HERO_CTAS.secondary.label, "Create an instant database"); assert.equal(POSTGRES_HERO_CTAS.primary.href, "https://console.prisma.io"); @@ -424,14 +438,19 @@ test("Kysely, Mongoose, and Convex Article JSON-LD authors are a Person with con "https://www.linkedin.com/in/martjvr/", "https://github.com/vanrensbird", ]); - for (const author of [kysely.KYSELY_AUTHOR, mongoose.MONGOOSE_AUTHOR, convex.CONVEX_AUTHOR]) { + for (const author of [ + kysely.KYSELY_AUTHOR, + mongoose.MONGOOSE_AUTHOR, + convex.CONVEX_AUTHOR, + neon.NEON_AUTHOR, + ]) { const person = comparisonAuthorPerson(author, getBaseUrl()); assert.equal(person["@type"], "Person"); assert.equal(person.name, author.name); assert.equal(person.url, new URL(author.path, getBaseUrl()).toString()); assert.deepEqual(person.sameAs, hrefs); } - for (const page of ["kysely/page.tsx", "mongoose/page.tsx", "convex/page.tsx"]) { + for (const page of ["kysely/page.tsx", "mongoose/page.tsx", "convex/page.tsx", "neon/page.tsx"]) { const source = readFileSync(join(compareDir, page), "utf8"); assert.match(source, /comparisonAuthorPerson\(/); assert.doesNotMatch(source, /sameAs:\s*\[/); @@ -1002,3 +1021,98 @@ test("convex metadata advertises Markdown and JSON alternates", () => { assert.match(types["text/markdown"] ?? "", /\/compare\/convex\.md$/); assert.match(types["application/json"] ?? "", /\/compare\/convex\.json$/); }); + +test("Neon Markdown carries the byline, disclosure, and Sources on the shared chrome", () => { + const page = getMarkdownPage("/compare/neon"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const minutes = getNeonReadingStats().minutes; + assert.ok( + markdown.includes( + `By [${neon.NEON_AUTHOR.name}](${neon.NEON_AUTHOR.path}) · Updated ${neon.NEON_UPDATED.label} · ${minutes} min read`, + ), + ); + for (const social of MARTIN_SOCIALS) { + assert.ok( + markdown.includes(`[${social.label}](${social.href})`), + `missing ${social.label} profile`, + ); + } + assert.ok(markdown.includes("Checked October 2026.")); + assert.ok(markdown.includes(`dateModified: ${neon.NEON_UPDATED.iso}`)); + assert.ok(markdown.includes("Prisma makes Prisma Postgres")); + assert.ok(markdown.includes("\n## Sources\n")); + assert.ok(markdown.includes("\n## When should I choose Neon or Prisma Postgres?\n")); + const choose = markdown.slice( + markdown.indexOf("## When should I choose Neon or Prisma Postgres?"), + markdown.indexOf("## How do Neon and Prisma Postgres compare?"), + ); + assert.match(choose, /^\| Neon \| Prisma Postgres \|$/m); + assert.doesNotMatch(choose, /### Neon/); + assert.ok(markdown.includes("\n## How do Neon and Prisma Postgres compare?\n")); + assert.ok(markdown.includes("\n## Where does each one fit?\n")); + assert.ok(markdown.includes("\n## How much do Neon and Prisma Postgres cost?\n")); + assert.ok(markdown.includes("| Feature | Neon | Prisma Postgres |")); + assert.ok(markdown.includes("| Cost | Neon | Prisma Postgres |")); + assert.ok(markdown.includes("$0.106")); + assert.ok(markdown.includes("$0.222")); + assert.ok(markdown.includes("$0.35")); + assert.doesNotMatch(markdown, /database branching/); + assert.doesNotMatch(markdown, /one plan|one bill/); + assert.doesNotMatch(markdown, /zero cold starts|no cold starts/); + assert.doesNotMatch(markdown, /requires Prisma ORM/); + assert.doesNotMatch(markdown, /the only|the first|the best/); + assert.doesNotMatch(markdown, /listed in one place/); + assert.doesNotMatch(markdown, /When should I choose Kysely/); + assert.doesNotMatch(markdown, /Copy link|Copied/); + const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /neon: "\/compare\/neon\.md"/); + const sections = readFileSync(join(compareDir, "neon/_sections.tsx"), "utf8"); + assert.match(sections, /comparisonChooseTable/); + assert.match(sections, /max-w-4xl text-balance font-heading/); + assert.match(sections, /mt-6 max-w-3xl text-pretty/); + assert.match(sections, /comparisonProofCards/); + assert.match(sections, /comparisonDisclosure/); + assert.doesNotMatch(sections, /defaultValue=/); + assert.doesNotMatch(sections, /PageAiActions/); + const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); + assert.match(ai, /pageMdUrl.includes\("\/compare\/neon\.md"\)/); + assert.match(ai, /I am choosing between Prisma Postgres and Neon for a TypeScript app/); + assert.match(ai, /Neon Functions, Auth, Object Storage, and AI Gateway/); +}); + +test("neon JSON facts carry a source and dateChecked on every value", async () => { + const facts = getNeonFacts(); + assert.equal(facts.path, "/compare/neon"); + assert.equal(facts.dateChecked, neon.NEON_UPDATED.iso); + assert.equal(facts.tools.neon.softwareVersion, "current"); + assert.equal(facts.features.length, neon.COMPARE_ROWS.length); + for (const row of [...facts.features, ...facts.pricing]) { + assert.match(row.neon.source, /^https?:\/\//); + assert.match(row.prisma.source, /^https?:\/\//); + assert.equal(row.neon.dateChecked, facts.dateChecked); + assert.equal(row.prisma.dateChecked, facts.dateChecked); + } + const response = getNeonFactsResponse(); + assert.match(response.headers.get("content-type") ?? "", /^application\/json/); + assert.deepEqual(await response.json(), facts); + + const fields = getNeonArticleFields(); + const stats = getNeonReadingStats(); + assert.equal(fields.datePublished, neon.NEON_PUBLISHED.iso); + assert.equal(fields.dateModified, neon.NEON_UPDATED.iso); + assert.equal(WORDS_PER_MINUTE, 225); + assert.equal(fields.wordCount, stats.wordCount); + assert.equal(fields.timeRequired, `PT${stats.minutes}M`); + assert.equal(stats.minutes, readingMinutes(stats.wordCount)); + assert.ok(stats.wordCount > 800); + assert.equal(fields.about[0]?.name, "Neon"); + assert.equal(fields.about[1]?.name, "Prisma Postgres"); +}); + +test("neon metadata advertises Markdown and JSON alternates", () => { + const types = getComparisonMetadata("neon").alternates?.types; + assert.ok(types); + assert.match(types["text/markdown"] ?? "", /\/compare\/neon\.md$/); + assert.match(types["application/json"] ?? "", /\/compare\/neon\.json$/); +}); diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index 2b3b603ca0..754f67dc24 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -13,6 +13,8 @@ import { CHECKED as KYSELY_CHECKED, KYSELY_UPDATED } from "@/app/compare/kysely/ import { getKyselyPageContent } from "@/app/compare/kysely/_sections"; import { CHECKED as MONGOOSE_CHECKED, MONGOOSE_UPDATED } from "@/app/compare/mongoose/_content"; import { getMongoosePageContent } from "@/app/compare/mongoose/_sections"; +import { CHECKED as NEON_CHECKED, NEON_UPDATED } from "@/app/compare/neon/_content"; +import { getNeonPageContent } from "@/app/compare/neon/_sections"; import type { MarkdownPage } from "@/lib/markdown-pages"; import { heading, joinBlocks } from "./blocks"; import { renderComparisonContent } from "./comparison-content"; @@ -21,6 +23,7 @@ export const comparisonContent = { convex: getConvexPageContent, kysely: getKyselyPageContent, mongoose: getMongoosePageContent, + neon: getNeonPageContent, } satisfies Record ReactNode>; /** Register complete comparison renditions using the same trees as the HTML pages. */ @@ -65,7 +68,9 @@ export function getComparisonMarkdownPages(): Record = { "/compare/convex": ["src/app/compare/convex/_sections.tsx"], "/compare/kysely": ["src/app/compare/kysely/_sections.tsx"], "/compare/mongoose": ["src/app/compare/mongoose/_sections.tsx"], + "/compare/neon": ["src/app/compare/neon/_sections.tsx"], "/mcp": [ "src/app/mcp/page.tsx", "src/app/mcp/_components/mcp-hero-section.tsx", @@ -117,6 +118,7 @@ const MIN_LITERAL_HEADINGS: Record = { "/compare/convex": 10, "/compare/kysely": 10, "/compare/mongoose": 10, + "/compare/neon": 10, }; const HEADING_PATTERN = From b60d4cc8487f1429ad56b453b450215104e783a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:12:48 +0000 Subject: [PATCH 46/52] Compare: add Prisma Postgres vs Supabase on the shared chrome Head-to-head at /compare/supabase for managed PostgreSQL versus a Postgres BaaS. Live pricing on 19 October 2026: Prisma Free storage is 2 GB (not the draft's 500 MB); HIPAA is Business at $129, not Pro. Supabase Pro $25, Team $599, Spend Cap excludes compute. Co-authored-by: rensburg --- .../from-supabase.mdx | 1 + apps/site/src/app/compare/article.tsx | 13 + apps/site/src/app/compare/comparisons.ts | 8 + apps/site/src/app/compare/page-ai-actions.tsx | 24 +- .../src/app/compare/supabase.json/route.ts | 11 + .../site/src/app/compare/supabase/_content.ts | 261 +++++++++++ .../src/app/compare/supabase/_sections.tsx | 415 ++++++++++++++++++ apps/site/src/app/compare/supabase/facts.ts | 86 ++++ apps/site/src/app/compare/supabase/page.tsx | 59 +++ .../components/sections/compare-links-data.ts | 6 + apps/site/src/lib/comparison-metadata.ts | 8 +- .../src/lib/markdown/comparisons.test.tsx | 126 +++++- apps/site/src/lib/markdown/comparisons.tsx | 7 +- .../src/lib/markdown/markdown-parity.test.ts | 2 + 14 files changed, 1016 insertions(+), 11 deletions(-) create mode 100644 apps/site/src/app/compare/supabase.json/route.ts create mode 100644 apps/site/src/app/compare/supabase/_content.ts create mode 100644 apps/site/src/app/compare/supabase/_sections.tsx create mode 100644 apps/site/src/app/compare/supabase/facts.ts create mode 100644 apps/site/src/app/compare/supabase/page.tsx 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 23ca04ae99..f6329e7495 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/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index 56ba935e93..c631dccb4d 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -31,6 +31,7 @@ const AI_MARKDOWN: Partial> = { mongoose: "/compare/mongoose.md", convex: "/compare/convex.md", neon: "/compare/neon.md", + supabase: "/compare/supabase.md", }; const TOC: Record = { @@ -86,6 +87,18 @@ const TOC: Record = { { 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" }, + ], }; /** diff --git a/apps/site/src/app/compare/comparisons.ts b/apps/site/src/app/compare/comparisons.ts index ece5feeed9..b963e8ce9c 100644 --- a/apps/site/src/app/compare/comparisons.ts +++ b/apps/site/src/app/compare/comparisons.ts @@ -2,6 +2,7 @@ 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 { SUPABASE_DESCRIPTION, SUPABASE_TITLE } from "./supabase/_content"; export const COMPARISON_INDEX = { path: "/compare", @@ -68,6 +69,13 @@ export const COMPARISONS = [ title: NEON_TITLE, description: NEON_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/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index bb154746ea..e718b4da09 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -37,6 +37,14 @@ const CONVEX_PROMPT = `I am choosing between Prisma Postgres and Convex for a Ty 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 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. @@ -47,13 +55,15 @@ Ask me up to three short questions if I need a copy of production data on previe /** 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/neon.md") - ? NEON_PROMPT - : pageMdUrl.includes("/compare/convex.md") - ? CONVEX_PROMPT - : pageMdUrl.includes("/compare/mongoose.md") - ? MONGOOSE_PROMPT - : KYSELY_PROMPT; + const body = 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} 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 0000000000..8afd477c21 --- /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 0000000000..a6297c4805 --- /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: TypeScript app hosting or a Postgres backend platform?"; + +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, Prisma Postgres, Supabase on the page? Chrome keeps competitor | Prisma like Neon. */ +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/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 0000000000..326c69cee7 --- /dev/null +++ b/apps/site/src/app/compare/supabase/_sections.tsx @@ -0,0 +1,415 @@ +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/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 0000000000..d7285dd8e0 --- /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 0000000000..51e541fc50 --- /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/components/sections/compare-links-data.ts b/apps/site/src/components/sections/compare-links-data.ts index 3c15efaf8e..59295c5134 100644 --- a/apps/site/src/components/sections/compare-links-data.ts +++ b/apps/site/src/components/sections/compare-links-data.ts @@ -47,6 +47,12 @@ export const COMPARE_PRISMA_LINKS: CompareLink[] = [ description: "Managed PostgreSQL compared: optional Prisma ORM and Prisma Compute in one project, or Neon Functions, Auth, Object Storage, and AI Gateway.", }, + { + label: "Prisma Postgres vs Supabase", + href: "/compare/supabase", + description: + "Managed PostgreSQL compared: optional Prisma ORM and Prisma Compute in one project, or Auth, Storage, Realtime, and Edge Functions.", + }, { label: "Neon vs Prisma Postgres for hosting and the database in one place", href: "/blog/neon-vs-prisma-postgres-app-hosting-in-one-place", diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index c265f28c48..1184d0aa42 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -17,7 +17,13 @@ export function getComparisonMetadata(slug: ComparisonSlug) { const types: Record = { "text/markdown": new URL(`${page.path}.md`, baseUrl).toString(), }; - if (slug === "kysely" || slug === "mongoose" || slug === "convex" || slug === "neon") { + if ( + slug === "kysely" || + slug === "mongoose" || + slug === "convex" || + slug === "neon" || + slug === "supabase" + ) { types["application/json"] = new URL(`${page.path}.json`, baseUrl).toString(); } return { diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index b62a94db24..aa5df6c2b5 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -14,6 +14,10 @@ import * as neon from "@/app/compare/neon/_content"; import { getNeonArticleFields, getNeonFacts } from "@/app/compare/neon/facts"; import { GET as getNeonFactsResponse } from "@/app/compare/neon.json/route"; import { getNeonReadingStats } from "@/app/compare/neon/_sections"; +import * as supabase from "@/app/compare/supabase/_content"; +import { getSupabaseArticleFields, getSupabaseFacts } from "@/app/compare/supabase/facts"; +import { GET as getSupabaseFactsResponse } from "@/app/compare/supabase.json/route"; +import { getSupabaseReadingStats } from "@/app/compare/supabase/_sections"; import { WORDS_PER_MINUTE, readingMinutes } from "@/app/compare/reading-time"; import { GET as getMongooseFactsResponse } from "@/app/compare/mongoose.json/route"; import { @@ -53,7 +57,7 @@ function plainMarkdown(text: string): string { for (const entry of COMPARISONS) { test(`${entry.slug} Markdown preserves comparison cells and every FAQ answer`, () => { - const content = { convex, kysely, mongoose, neon }[entry.slug]; + const content = { convex, kysely, mongoose, neon, supabase }[entry.slug]; const page = getMarkdownPage(`/compare/${entry.slug}`); assert.ok(page); const markdown = renderMarkdownDocument(page); @@ -375,6 +379,16 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () neonMarkdown, /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); + const supabasePage = getMarkdownPage("/compare/supabase"); + assert.ok(supabasePage); + const supabaseMarkdown = renderMarkdownDocument(supabasePage); + assert.ok(supabaseMarkdown.includes("Free to start, no credit card required.")); + assert.ok(supabaseMarkdown.includes("Trusted by 500K+ developers globally")); + assert.ok(supabaseMarkdown.includes("Prisma ORM is free and open source, and always will be.")); + assert.match( + supabaseMarkdown, + /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, + ); assert.equal(POSTGRES_HERO_CTAS.secondary.href, CREATE_DB_WEB); assert.equal(POSTGRES_HERO_CTAS.secondary.label, "Create an instant database"); assert.equal(POSTGRES_HERO_CTAS.primary.href, "https://console.prisma.io"); @@ -443,6 +457,7 @@ test("Kysely, Mongoose, and Convex Article JSON-LD authors are a Person with con mongoose.MONGOOSE_AUTHOR, convex.CONVEX_AUTHOR, neon.NEON_AUTHOR, + supabase.SUPABASE_AUTHOR, ]) { const person = comparisonAuthorPerson(author, getBaseUrl()); assert.equal(person["@type"], "Person"); @@ -450,7 +465,13 @@ test("Kysely, Mongoose, and Convex Article JSON-LD authors are a Person with con assert.equal(person.url, new URL(author.path, getBaseUrl()).toString()); assert.deepEqual(person.sameAs, hrefs); } - for (const page of ["kysely/page.tsx", "mongoose/page.tsx", "convex/page.tsx", "neon/page.tsx"]) { + for (const page of [ + "kysely/page.tsx", + "mongoose/page.tsx", + "convex/page.tsx", + "neon/page.tsx", + "supabase/page.tsx", + ]) { const source = readFileSync(join(compareDir, page), "utf8"); assert.match(source, /comparisonAuthorPerson\(/); assert.doesNotMatch(source, /sameAs:\s*\[/); @@ -1116,3 +1137,104 @@ test("neon metadata advertises Markdown and JSON alternates", () => { assert.match(types["text/markdown"] ?? "", /\/compare\/neon\.md$/); assert.match(types["application/json"] ?? "", /\/compare\/neon\.json$/); }); + +test("Supabase Markdown carries the byline, disclosure, and Sources on the shared chrome", () => { + const page = getMarkdownPage("/compare/supabase"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const minutes = getSupabaseReadingStats().minutes; + assert.ok( + markdown.includes( + `By [${supabase.SUPABASE_AUTHOR.name}](${supabase.SUPABASE_AUTHOR.path}) · Updated ${supabase.SUPABASE_UPDATED.label} · ${minutes} min read`, + ), + ); + for (const social of MARTIN_SOCIALS) { + assert.ok( + markdown.includes(`[${social.label}](${social.href})`), + `missing ${social.label} profile`, + ); + } + assert.ok(markdown.includes("Checked October 2026.")); + assert.ok(markdown.includes(`dateModified: ${supabase.SUPABASE_UPDATED.iso}`)); + assert.ok(markdown.includes("Prisma makes Prisma Postgres")); + assert.ok(markdown.includes("\n## Sources\n")); + assert.ok(markdown.includes("\n## When should I choose Supabase or Prisma Postgres?\n")); + const choose = markdown.slice( + markdown.indexOf("## When should I choose Supabase or Prisma Postgres?"), + markdown.indexOf("## How do Supabase and Prisma Postgres compare?"), + ); + assert.match(choose, /^\| Supabase \| Prisma Postgres \|$/m); + assert.doesNotMatch(choose, /### Supabase/); + assert.ok(markdown.includes("\n## How do Supabase and Prisma Postgres compare?\n")); + assert.ok(markdown.includes("\n## Where does each one fit?\n")); + assert.ok(markdown.includes("\n## How much do Supabase and Prisma Postgres cost?\n")); + assert.ok(markdown.includes("| Feature | Supabase | Prisma Postgres |")); + assert.ok(markdown.includes("| Cost | Supabase | Prisma Postgres |")); + assert.ok(markdown.includes("$25")); + assert.ok(markdown.includes("$599")); + assert.ok(markdown.includes("2 GB")); + assert.ok(markdown.includes("500 MB")); + assert.ok(markdown.includes("Business plan at $129")); + assert.doesNotMatch(markdown, /500 MB storage on Free/); + assert.doesNotMatch( + markdown, + /HIPAA starts with Pro|HIPAA from Pro|HIPAA support starts with Pro/, + ); + assert.doesNotMatch(markdown, /database branching/); + assert.doesNotMatch(markdown, /one plan|one bill/); + assert.doesNotMatch(markdown, /zero cold starts|no cold starts/); + assert.doesNotMatch(markdown, /requires Prisma ORM/); + assert.doesNotMatch(markdown, /listed in one place/); + assert.doesNotMatch(markdown, /When should I choose Kysely/); + assert.doesNotMatch(markdown, /Copy link|Copied/); + const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /supabase: "\/compare\/supabase\.md"/); + const sections = readFileSync(join(compareDir, "supabase/_sections.tsx"), "utf8"); + assert.match(sections, /comparisonChooseTable/); + assert.match(sections, /max-w-4xl text-balance font-heading/); + assert.match(sections, /mt-6 max-w-3xl text-pretty/); + assert.match(sections, /comparisonProofCards/); + assert.match(sections, /comparisonDisclosure/); + assert.doesNotMatch(sections, /defaultValue=/); + assert.doesNotMatch(sections, /PageAiActions/); + const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); + assert.match(ai, /pageMdUrl.includes\("\/compare\/supabase\.md"\)/); + assert.match(ai, /I am choosing between Prisma Postgres and Supabase for a TypeScript app/); + assert.match(ai, /Auth, Storage, Realtime, and Edge Functions/); +}); + +test("supabase JSON facts carry a source and dateChecked on every value", async () => { + const facts = getSupabaseFacts(); + assert.equal(facts.path, "/compare/supabase"); + assert.equal(facts.dateChecked, supabase.SUPABASE_UPDATED.iso); + assert.equal(facts.tools.supabase.softwareVersion, "current"); + assert.equal(facts.features.length, supabase.COMPARE_ROWS.length); + for (const row of [...facts.features, ...facts.pricing]) { + assert.match(row.supabase.source, /^https?:\/\//); + assert.match(row.prisma.source, /^https?:\/\//); + assert.equal(row.supabase.dateChecked, facts.dateChecked); + assert.equal(row.prisma.dateChecked, facts.dateChecked); + } + const response = getSupabaseFactsResponse(); + assert.match(response.headers.get("content-type") ?? "", /^application\/json/); + assert.deepEqual(await response.json(), facts); + + const fields = getSupabaseArticleFields(); + const stats = getSupabaseReadingStats(); + assert.equal(fields.datePublished, supabase.SUPABASE_PUBLISHED.iso); + assert.equal(fields.dateModified, supabase.SUPABASE_UPDATED.iso); + assert.equal(WORDS_PER_MINUTE, 225); + assert.equal(fields.wordCount, stats.wordCount); + assert.equal(fields.timeRequired, `PT${stats.minutes}M`); + assert.equal(stats.minutes, readingMinutes(stats.wordCount)); + assert.ok(stats.wordCount > 800); + assert.equal(fields.about[0]?.name, "Supabase"); + assert.equal(fields.about[1]?.name, "Prisma Postgres"); +}); + +test("supabase metadata advertises Markdown and JSON alternates", () => { + const types = getComparisonMetadata("supabase").alternates?.types; + assert.ok(types); + assert.match(types["text/markdown"] ?? "", /\/compare\/supabase\.md$/); + assert.match(types["application/json"] ?? "", /\/compare\/supabase\.json$/); +}); diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index 754f67dc24..38b1f521e7 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -15,6 +15,8 @@ import { CHECKED as MONGOOSE_CHECKED, MONGOOSE_UPDATED } from "@/app/compare/mon import { getMongoosePageContent } from "@/app/compare/mongoose/_sections"; import { CHECKED as NEON_CHECKED, NEON_UPDATED } from "@/app/compare/neon/_content"; import { getNeonPageContent } from "@/app/compare/neon/_sections"; +import { CHECKED as SUPABASE_CHECKED, SUPABASE_UPDATED } from "@/app/compare/supabase/_content"; +import { getSupabasePageContent } from "@/app/compare/supabase/_sections"; import type { MarkdownPage } from "@/lib/markdown-pages"; import { heading, joinBlocks } from "./blocks"; import { renderComparisonContent } from "./comparison-content"; @@ -24,6 +26,7 @@ export const comparisonContent = { kysely: getKyselyPageContent, mongoose: getMongoosePageContent, neon: getNeonPageContent, + supabase: getSupabasePageContent, } satisfies Record ReactNode>; /** Register complete comparison renditions using the same trees as the HTML pages. */ @@ -70,7 +73,9 @@ export function getComparisonMarkdownPages(): Record = { "/compare/kysely": ["src/app/compare/kysely/_sections.tsx"], "/compare/mongoose": ["src/app/compare/mongoose/_sections.tsx"], "/compare/neon": ["src/app/compare/neon/_sections.tsx"], + "/compare/supabase": ["src/app/compare/supabase/_sections.tsx"], "/mcp": [ "src/app/mcp/page.tsx", "src/app/mcp/_components/mcp-hero-section.tsx", @@ -119,6 +120,7 @@ const MIN_LITERAL_HEADINGS: Record = { "/compare/kysely": 10, "/compare/mongoose": 10, "/compare/neon": 10, + "/compare/supabase": 10, }; const HEADING_PATTERN = From e9291e8f6d4ee3de8d53e159001751ca451a8117 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:14:29 +0000 Subject: [PATCH 47/52] site: advertise Neon and Supabase Markdown headers The comparison registry includes both pages, so Vary and Link have to list them too. The site unit test compares that list to AGENT_MARKDOWN_PATHS. Co-authored-by: rensburg --- apps/site/next.config.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs index 500ba3167f..3bc23f650f 100644 --- a/apps/site/next.config.mjs +++ b/apps/site/next.config.mjs @@ -273,6 +273,8 @@ const agentMarkdownPaths = [ "/compare/convex", "/compare/kysely", "/compare/mongoose", + "/compare/neon", + "/compare/supabase", ]; /** From 9247978f2a67caa4758479fc0da01f39a62cfc17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:16:59 +0000 Subject: [PATCH 48/52] ci: ignore unreleased Neon and Supabase compare links Lychee checks absolute prisma.io URLs against production. Those two comparison pages ship in this PR, so the migration guides 404 until merge. Drop the exclude once the pages are live. Co-authored-by: rensburg --- .github/workflows/lychee.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index cf475e71a2..531b0cad30 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 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|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|supabase)$' --accept 200,201,204,301,302,303,307,308,403,429 ${{ steps.changed-docs.outputs.all_changed_files }} output: lychee/out-retry.md From 39804269e6b3f88e065a66a2e42923653364c70a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:18:22 +0000 Subject: [PATCH 49/52] Compare: add Prisma Postgres vs PlanetScale on the shared chrome Head-to-head at /compare/planetscale, using the frozen Postgres template. Live-checked 19 October 2026: PlanetScale single-node PS-5 is $5, Postgres Metal M-10 starts at $50, Neki is platform preview (not production). Prisma Free storage is 2 GB, not the draft's 500 MB. Co-authored-by: rensburg --- .github/workflows/lychee.yml | 6 +- .../import-from-existing-database.mdx | 2 +- apps/site/next.config.mjs | 1 + apps/site/src/app/compare/article.tsx | 13 + apps/site/src/app/compare/comparisons.ts | 8 + apps/site/src/app/compare/page-ai-actions.tsx | 28 +- .../src/app/compare/planetscale.json/route.ts | 11 + .../src/app/compare/planetscale/_content.ts | 255 +++++++++++ .../src/app/compare/planetscale/_sections.tsx | 418 ++++++++++++++++++ .../site/src/app/compare/planetscale/facts.ts | 86 ++++ .../site/src/app/compare/planetscale/page.tsx | 59 +++ .../components/sections/compare-links-data.ts | 6 + apps/site/src/lib/comparison-metadata.ts | 1 + .../src/lib/markdown/comparisons.test.tsx | 118 ++++- apps/site/src/lib/markdown/comparisons.tsx | 14 +- .../src/lib/markdown/markdown-parity.test.ts | 2 + 16 files changed, 1011 insertions(+), 17 deletions(-) create mode 100644 apps/site/src/app/compare/planetscale.json/route.ts create mode 100644 apps/site/src/app/compare/planetscale/_content.ts create mode 100644 apps/site/src/app/compare/planetscale/_sections.tsx create mode 100644 apps/site/src/app/compare/planetscale/facts.ts create mode 100644 apps/site/src/app/compare/planetscale/page.tsx diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index 531b0cad30..4cf1c94aea 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -36,7 +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 and /compare/supabase ship with PR #8516; drop that exclude once they are 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 @@ -57,7 +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|supabase)$' + --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 @@ -92,7 +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|supabase)$' + --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/apps/docs/content/docs/postgres/import-from-existing-database.mdx b/apps/docs/content/docs/postgres/import-from-existing-database.mdx index 1a72f0bfe0..3ac1ce2158 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](/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 3bc23f650f..2499952fa0 100644 --- a/apps/site/next.config.mjs +++ b/apps/site/next.config.mjs @@ -274,6 +274,7 @@ const agentMarkdownPaths = [ "/compare/kysely", "/compare/mongoose", "/compare/neon", + "/compare/planetscale", "/compare/supabase", ]; diff --git a/apps/site/src/app/compare/article.tsx b/apps/site/src/app/compare/article.tsx index c631dccb4d..90b9359cd8 100644 --- a/apps/site/src/app/compare/article.tsx +++ b/apps/site/src/app/compare/article.tsx @@ -31,6 +31,7 @@ const AI_MARKDOWN: Partial> = { mongoose: "/compare/mongoose.md", convex: "/compare/convex.md", neon: "/compare/neon.md", + planetscale: "/compare/planetscale.md", supabase: "/compare/supabase.md", }; @@ -87,6 +88,18 @@ const TOC: Record = { { 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" }, diff --git a/apps/site/src/app/compare/comparisons.ts b/apps/site/src/app/compare/comparisons.ts index b963e8ce9c..331b8d1bfb 100644 --- a/apps/site/src/app/compare/comparisons.ts +++ b/apps/site/src/app/compare/comparisons.ts @@ -2,6 +2,7 @@ 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 = { @@ -69,6 +70,13 @@ export const COMPARISONS = [ title: NEON_TITLE, description: NEON_DESCRIPTION, }, + { + slug: "planetscale", + category: "postgres", + competitor: "PlanetScale", + title: PLANETSCALE_TITLE, + description: PLANETSCALE_DESCRIPTION, + }, { slug: "supabase", category: "postgres", diff --git a/apps/site/src/app/compare/page-ai-actions.tsx b/apps/site/src/app/compare/page-ai-actions.tsx index e718b4da09..4246d05b2c 100644 --- a/apps/site/src/app/compare/page-ai-actions.tsx +++ b/apps/site/src/app/compare/page-ai-actions.tsx @@ -37,6 +37,14 @@ const CONVEX_PROMPT = `I am choosing between Prisma Postgres and Convex for a Ty 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. @@ -55,15 +63,17 @@ Ask me up to three short questions if I need a copy of production data on previe /** 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/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; + 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} 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 0000000000..237d4031b0 --- /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 0000000000..0902b82890 --- /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", + 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 0000000000..49579b431a --- /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: "Legal", href: "https://planetscale.com/legal" }, + ], + }, + { + 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 0000000000..dbe188a8cf --- /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 0000000000..1882b68a7b --- /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/components/sections/compare-links-data.ts b/apps/site/src/components/sections/compare-links-data.ts index 59295c5134..de6d99d3ab 100644 --- a/apps/site/src/components/sections/compare-links-data.ts +++ b/apps/site/src/components/sections/compare-links-data.ts @@ -47,6 +47,12 @@ export const COMPARE_PRISMA_LINKS: CompareLink[] = [ description: "Managed PostgreSQL compared: optional Prisma ORM and Prisma Compute in one project, or Neon Functions, Auth, Object Storage, and AI Gateway.", }, + { + label: "Prisma Postgres vs PlanetScale", + href: "/compare/planetscale", + description: + "Managed PostgreSQL compared: optional Prisma ORM and Prisma Compute in one project, or database-first SKUs, Metal, and Neki.", + }, { label: "Prisma Postgres vs Supabase", href: "/compare/supabase", diff --git a/apps/site/src/lib/comparison-metadata.ts b/apps/site/src/lib/comparison-metadata.ts index 1184d0aa42..0dfb158d80 100644 --- a/apps/site/src/lib/comparison-metadata.ts +++ b/apps/site/src/lib/comparison-metadata.ts @@ -22,6 +22,7 @@ export function getComparisonMetadata(slug: ComparisonSlug) { slug === "mongoose" || slug === "convex" || slug === "neon" || + slug === "planetscale" || slug === "supabase" ) { types["application/json"] = new URL(`${page.path}.json`, baseUrl).toString(); diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index aa5df6c2b5..a08d25154a 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -14,6 +14,10 @@ import * as neon from "@/app/compare/neon/_content"; import { getNeonArticleFields, getNeonFacts } from "@/app/compare/neon/facts"; import { GET as getNeonFactsResponse } from "@/app/compare/neon.json/route"; import { getNeonReadingStats } from "@/app/compare/neon/_sections"; +import * as planetscale from "@/app/compare/planetscale/_content"; +import { getPlanetScaleArticleFields, getPlanetScaleFacts } from "@/app/compare/planetscale/facts"; +import { GET as getPlanetScaleFactsResponse } from "@/app/compare/planetscale.json/route"; +import { getPlanetScaleReadingStats } from "@/app/compare/planetscale/_sections"; import * as supabase from "@/app/compare/supabase/_content"; import { getSupabaseArticleFields, getSupabaseFacts } from "@/app/compare/supabase/facts"; import { GET as getSupabaseFactsResponse } from "@/app/compare/supabase.json/route"; @@ -57,7 +61,7 @@ function plainMarkdown(text: string): string { for (const entry of COMPARISONS) { test(`${entry.slug} Markdown preserves comparison cells and every FAQ answer`, () => { - const content = { convex, kysely, mongoose, neon, supabase }[entry.slug]; + const content = { convex, kysely, mongoose, neon, planetscale, supabase }[entry.slug]; const page = getMarkdownPage(`/compare/${entry.slug}`); assert.ok(page); const markdown = renderMarkdownDocument(page); @@ -389,6 +393,18 @@ test("kysely Markdown keeps the 500K Prisma ORM figure in the closing copy", () supabaseMarkdown, /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, ); + const planetscalePage = getMarkdownPage("/compare/planetscale"); + assert.ok(planetscalePage); + const planetscaleMarkdown = renderMarkdownDocument(planetscalePage); + assert.ok(planetscaleMarkdown.includes("Free to start, no credit card required.")); + assert.ok(planetscaleMarkdown.includes("Trusted by 500K+ developers globally")); + assert.ok( + planetscaleMarkdown.includes("Prisma ORM is free and open source, and always will be."), + ); + assert.match( + planetscaleMarkdown, + /Need a database\? \[Create an instant database\]\(https:\/\/create-db\.prisma\.io\/web\)/, + ); assert.equal(POSTGRES_HERO_CTAS.secondary.href, CREATE_DB_WEB); assert.equal(POSTGRES_HERO_CTAS.secondary.label, "Create an instant database"); assert.equal(POSTGRES_HERO_CTAS.primary.href, "https://console.prisma.io"); @@ -457,6 +473,7 @@ test("Kysely, Mongoose, and Convex Article JSON-LD authors are a Person with con mongoose.MONGOOSE_AUTHOR, convex.CONVEX_AUTHOR, neon.NEON_AUTHOR, + planetscale.PLANETSCALE_AUTHOR, supabase.SUPABASE_AUTHOR, ]) { const person = comparisonAuthorPerson(author, getBaseUrl()); @@ -470,6 +487,7 @@ test("Kysely, Mongoose, and Convex Article JSON-LD authors are a Person with con "mongoose/page.tsx", "convex/page.tsx", "neon/page.tsx", + "planetscale/page.tsx", "supabase/page.tsx", ]) { const source = readFileSync(join(compareDir, page), "utf8"); @@ -1238,3 +1256,101 @@ test("supabase metadata advertises Markdown and JSON alternates", () => { assert.match(types["text/markdown"] ?? "", /\/compare\/supabase\.md$/); assert.match(types["application/json"] ?? "", /\/compare\/supabase\.json$/); }); + +test("PlanetScale Markdown carries the byline, disclosure, and Sources on the shared chrome", () => { + const page = getMarkdownPage("/compare/planetscale"); + assert.ok(page); + const markdown = renderMarkdownDocument(page); + const minutes = getPlanetScaleReadingStats().minutes; + assert.ok( + markdown.includes( + `By [${planetscale.PLANETSCALE_AUTHOR.name}](${planetscale.PLANETSCALE_AUTHOR.path}) · Updated ${planetscale.PLANETSCALE_UPDATED.label} · ${minutes} min read`, + ), + ); + for (const social of MARTIN_SOCIALS) { + assert.ok( + markdown.includes(`[${social.label}](${social.href})`), + `missing ${social.label} profile`, + ); + } + assert.ok(markdown.includes("Checked October 2026.")); + assert.ok(markdown.includes(`dateModified: ${planetscale.PLANETSCALE_UPDATED.iso}`)); + assert.ok(markdown.includes("Prisma makes Prisma Postgres")); + assert.ok(markdown.includes("\n## Sources\n")); + assert.ok(markdown.includes("\n## When should I choose PlanetScale or Prisma Postgres?\n")); + const choose = markdown.slice( + markdown.indexOf("## When should I choose PlanetScale or Prisma Postgres?"), + markdown.indexOf("## How do PlanetScale and Prisma Postgres compare?"), + ); + assert.match(choose, /^\| PlanetScale \| Prisma Postgres \|$/m); + assert.doesNotMatch(choose, /### PlanetScale/); + assert.ok(markdown.includes("\n## How do PlanetScale and Prisma Postgres compare?\n")); + assert.ok(markdown.includes("\n## Where does each one fit?\n")); + assert.ok(markdown.includes("\n## How much do PlanetScale and Prisma Postgres cost?\n")); + assert.ok(markdown.includes("| Feature | PlanetScale | Prisma Postgres |")); + assert.ok(markdown.includes("| Cost | PlanetScale | Prisma Postgres |")); + assert.ok(markdown.includes("$5")); + assert.ok(markdown.includes("$50")); + assert.ok(markdown.includes("2 GB")); + assert.ok(markdown.includes("Neki")); + assert.ok(markdown.includes("platform preview")); + assert.doesNotMatch(markdown, /500 MB storage on Free/); + assert.doesNotMatch(markdown, /database branching/); + assert.doesNotMatch(markdown, /one plan|one bill/); + assert.doesNotMatch(markdown, /zero cold starts|no cold starts/); + assert.doesNotMatch(markdown, /requires Prisma ORM/); + assert.doesNotMatch(markdown, /the only|the first|the best/); + assert.doesNotMatch(markdown, /listed in one place/); + assert.doesNotMatch(markdown, /When should I choose Kysely/); + assert.doesNotMatch(markdown, /Copy link|Copied/); + const article = readFileSync(join(compareDir, "article.tsx"), "utf8"); + assert.match(article, /planetscale: "\/compare\/planetscale\.md"/); + const sections = readFileSync(join(compareDir, "planetscale/_sections.tsx"), "utf8"); + assert.match(sections, /comparisonChooseTable/); + assert.match(sections, /max-w-4xl text-balance font-heading/); + assert.match(sections, /mt-6 max-w-3xl text-pretty/); + assert.match(sections, /comparisonProofCards/); + assert.match(sections, /comparisonDisclosure/); + assert.doesNotMatch(sections, /defaultValue=/); + assert.doesNotMatch(sections, /PageAiActions/); + const ai = readFileSync(join(compareDir, "page-ai-actions.tsx"), "utf8"); + assert.match(ai, /pageMdUrl.includes\("\/compare\/planetscale\.md"\)/); + assert.match(ai, /I am choosing between Prisma Postgres and PlanetScale for a TypeScript app/); + assert.match(ai, /database-first Postgres SKUs, Metal, HA, or Neki/); +}); + +test("planetscale JSON facts carry a source and dateChecked on every value", async () => { + const facts = getPlanetScaleFacts(); + assert.equal(facts.path, "/compare/planetscale"); + assert.equal(facts.dateChecked, planetscale.PLANETSCALE_UPDATED.iso); + assert.equal(facts.tools.planetscale.softwareVersion, "current"); + assert.equal(facts.features.length, planetscale.COMPARE_ROWS.length); + for (const row of [...facts.features, ...facts.pricing]) { + assert.match(row.planetscale.source, /^https?:\/\//); + assert.match(row.prisma.source, /^https?:\/\//); + assert.equal(row.planetscale.dateChecked, facts.dateChecked); + assert.equal(row.prisma.dateChecked, facts.dateChecked); + } + const response = getPlanetScaleFactsResponse(); + assert.match(response.headers.get("content-type") ?? "", /^application\/json/); + assert.deepEqual(await response.json(), facts); + + const fields = getPlanetScaleArticleFields(); + const stats = getPlanetScaleReadingStats(); + assert.equal(fields.datePublished, planetscale.PLANETSCALE_PUBLISHED.iso); + assert.equal(fields.dateModified, planetscale.PLANETSCALE_UPDATED.iso); + assert.equal(WORDS_PER_MINUTE, 225); + assert.equal(fields.wordCount, stats.wordCount); + assert.equal(fields.timeRequired, `PT${stats.minutes}M`); + assert.equal(stats.minutes, readingMinutes(stats.wordCount)); + assert.ok(stats.wordCount > 800); + assert.equal(fields.about[0]?.name, "PlanetScale"); + assert.equal(fields.about[1]?.name, "Prisma Postgres"); +}); + +test("planetscale metadata advertises Markdown and JSON alternates", () => { + const types = getComparisonMetadata("planetscale").alternates?.types; + assert.ok(types); + assert.match(types["text/markdown"] ?? "", /\/compare\/planetscale\.md$/); + assert.match(types["application/json"] ?? "", /\/compare\/planetscale\.json$/); +}); diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index 38b1f521e7..3aea52cb12 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -15,6 +15,11 @@ import { CHECKED as MONGOOSE_CHECKED, MONGOOSE_UPDATED } from "@/app/compare/mon import { getMongoosePageContent } from "@/app/compare/mongoose/_sections"; import { CHECKED as NEON_CHECKED, NEON_UPDATED } from "@/app/compare/neon/_content"; import { getNeonPageContent } from "@/app/compare/neon/_sections"; +import { + CHECKED as PLANETSCALE_CHECKED, + PLANETSCALE_UPDATED, +} from "@/app/compare/planetscale/_content"; +import { getPlanetScalePageContent } from "@/app/compare/planetscale/_sections"; import { CHECKED as SUPABASE_CHECKED, SUPABASE_UPDATED } from "@/app/compare/supabase/_content"; import { getSupabasePageContent } from "@/app/compare/supabase/_sections"; import type { MarkdownPage } from "@/lib/markdown-pages"; @@ -26,6 +31,7 @@ export const comparisonContent = { kysely: getKyselyPageContent, mongoose: getMongoosePageContent, neon: getNeonPageContent, + planetscale: getPlanetScalePageContent, supabase: getSupabasePageContent, } satisfies Record ReactNode>; @@ -73,9 +79,11 @@ export function getComparisonMarkdownPages(): Record = { "/compare/kysely": ["src/app/compare/kysely/_sections.tsx"], "/compare/mongoose": ["src/app/compare/mongoose/_sections.tsx"], "/compare/neon": ["src/app/compare/neon/_sections.tsx"], + "/compare/planetscale": ["src/app/compare/planetscale/_sections.tsx"], "/compare/supabase": ["src/app/compare/supabase/_sections.tsx"], "/mcp": [ "src/app/mcp/page.tsx", @@ -120,6 +121,7 @@ const MIN_LITERAL_HEADINGS: Record = { "/compare/kysely": 10, "/compare/mongoose": 10, "/compare/neon": 10, + "/compare/planetscale": 10, "/compare/supabase": 10, }; From 6fe4ed15f4730fc58a6e7ddf99cb15756d861866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:23:07 +0000 Subject: [PATCH 50/52] docs: link the PlanetScale comparison on the marketing site The docs link checker treats /compare/planetscale as a docs route. The page lives on prisma.io, same as the Neon and Supabase links. Co-authored-by: rensburg --- .../content/docs/postgres/import-from-existing-database.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3ac1ce2158..61527d5c32 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. PlanetScale Postgres uses the same dump and restore over a direct (non-PgBouncer) connection; [Prisma Postgres vs PlanetScale](/compare/planetscale) is the head-to-head. +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. From f4c47be662c19740eb3e3fa3185796696cd04691 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 07:30:49 +0000 Subject: [PATCH 51/52] Compare: retitle Prisma Postgres vs Supabase Use the exact H1 "Prisma Postgres vs Supabase: what's the difference and which should I pick?" for the page title, metadata, JSON-LD, Markdown, and directory listing. Co-authored-by: rensburg --- apps/site/src/app/compare/supabase/_content.ts | 2 +- apps/site/src/lib/markdown/comparisons.test.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/site/src/app/compare/supabase/_content.ts b/apps/site/src/app/compare/supabase/_content.ts index a6297c4805..47bc1921fc 100644 --- a/apps/site/src/app/compare/supabase/_content.ts +++ b/apps/site/src/app/compare/supabase/_content.ts @@ -1,7 +1,7 @@ export const SUPABASE_PATH = "/compare/supabase"; export const SUPABASE_TITLE = - "Prisma Postgres vs Supabase: TypeScript app hosting or a Postgres backend platform?"; + "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."; diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index a08d25154a..fb6ae22f79 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -1160,6 +1160,19 @@ test("Supabase Markdown carries the byline, disclosure, and Sources on the share const page = getMarkdownPage("/compare/supabase"); assert.ok(page); const markdown = renderMarkdownDocument(page); + assert.equal( + supabase.SUPABASE_TITLE, + "Prisma Postgres vs Supabase: what's the difference and which should I pick?", + ); + assert.equal(page.title, supabase.SUPABASE_TITLE); + assert.equal(page.h1, supabase.SUPABASE_TITLE); + assert.ok(markdown.startsWith(`# ${supabase.SUPABASE_TITLE} (${supabase.SUPABASE_PATH})\n`)); + assert.doesNotMatch(markdown, /TypeScript app hosting or a Postgres backend platform/); + assert.equal( + getComparisonStructuredData("supabase")["@graph"][0]?.headline, + supabase.SUPABASE_TITLE, + ); + assert.equal(getComparisonMetadata("supabase").openGraph?.title, supabase.SUPABASE_TITLE); const minutes = getSupabaseReadingStats().minutes; assert.ok( markdown.includes( From 6049feee5d81076ea991882d83a735429c350948 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:17:34 +0200 Subject: [PATCH 52/52] Compare: resolve review feedback on the Postgres comparison pages - Count table text in the reading-time walker so the byline and JSON-LD wordCount cover the choose, feature, and pricing tables. - Drop the global scroll-padding-top; headings already carry scroll-mt-28. - Strip fragment links in FAQ structured data, matching the renderer. - Wrap the answer-page summary in a div so multi-paragraph summaries are valid HTML. - Replace the slug ternary with a typed freshness lookup. - Fix the openGraph title typecheck in the comparisons test. - Point the Supabase Spend Cap source and the PlanetScale legal source at URLs that resolve without a redirect; format neon.ts as code. Co-Authored-By: Claude Fable 5.1 --- apps/site/src/app/compare/neon/_content.ts | 4 +-- .../src/app/compare/planetscale/_content.ts | 2 +- .../src/app/compare/planetscale/_sections.tsx | 2 +- apps/site/src/app/compare/reading-time.ts | 25 ++++++++++++++++++- .../site/src/app/compare/supabase/_content.ts | 4 +-- .../src/app/compare/supabase/_sections.tsx | 5 +++- apps/site/src/app/globals.css | 1 - .../components/answer-pages/answer-page.tsx | 4 +-- .../src/lib/markdown/comparisons.test.tsx | 3 ++- apps/site/src/lib/markdown/comparisons.tsx | 24 ++++++++---------- apps/site/src/lib/structured-data.ts | 2 +- 11 files changed, 50 insertions(+), 26 deletions(-) diff --git a/apps/site/src/app/compare/neon/_content.ts b/apps/site/src/app/compare/neon/_content.ts index bb64a65556..1c7f1f7e7b 100644 --- a/apps/site/src/app/compare/neon/_content.ts +++ b/apps/site/src/app/compare/neon/_content.ts @@ -26,7 +26,7 @@ export const CHOOSE_PRISMA = [ ]; 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 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.", @@ -93,7 +93,7 @@ export const COMPARE_ROWS: readonly CompareRow[] = [ }, { 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.", + 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", diff --git a/apps/site/src/app/compare/planetscale/_content.ts b/apps/site/src/app/compare/planetscale/_content.ts index 0902b82890..3f0243ee40 100644 --- a/apps/site/src/app/compare/planetscale/_content.ts +++ b/apps/site/src/app/compare/planetscale/_content.ts @@ -239,7 +239,7 @@ export const TOOLS = { planetscale: { name: "PlanetScale", softwareVersion: "current", - license: "https://planetscale.com/legal", + license: "https://planetscale.com/legal/siteterms", codeRepository: "https://github.com/planetscale", programmingLanguage: "Go", url: "https://planetscale.com", diff --git a/apps/site/src/app/compare/planetscale/_sections.tsx b/apps/site/src/app/compare/planetscale/_sections.tsx index 49579b431a..ee78dd0e6d 100644 --- a/apps/site/src/app/compare/planetscale/_sections.tsx +++ b/apps/site/src/app/compare/planetscale/_sections.tsx @@ -77,7 +77,7 @@ const SOURCES = [ label: "Neki platform preview limitations", href: "https://planetscale.com/docs/neki/platform-preview-limitations", }, - { label: "Legal", href: "https://planetscale.com/legal" }, + { label: "Site terms", href: "https://planetscale.com/legal/siteterms" }, ], }, { diff --git a/apps/site/src/app/compare/reading-time.ts b/apps/site/src/app/compare/reading-time.ts index 90d52c7545..c4bcbd6307 100644 --- a/apps/site/src/app/compare/reading-time.ts +++ b/apps/site/src/app/compare/reading-time.ts @@ -1,5 +1,18 @@ 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. @@ -19,10 +32,20 @@ export function countWords(node: ReactNode): number { value.forEach(walk); return; } - if (!isValidElement<{ children?: ReactNode; "aria-hidden"?: boolean | "true" }>(value)) { + 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); diff --git a/apps/site/src/app/compare/supabase/_content.ts b/apps/site/src/app/compare/supabase/_content.ts index 47bc1921fc..b668ad3412 100644 --- a/apps/site/src/app/compare/supabase/_content.ts +++ b/apps/site/src/app/compare/supabase/_content.ts @@ -39,7 +39,7 @@ export type CompareRow = { sources: { supabase: string; prisma: string }; }; -/** Columns run Feature, Prisma Postgres, Supabase on the page? Chrome keeps competitor | Prisma like Neon. */ +/** Columns run Feature, Supabase, Prisma Postgres: competitor first, like the Neon page. */ export const COMPARE_ROWS: readonly CompareRow[] = [ { feature: "Database", @@ -200,7 +200,7 @@ export const PRICING: readonly { "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/spend-cap", + supabase: "https://supabase.com/docs/guides/platform/cost-control#spend-cap", prisma: "https://www.prisma.io/pricing", }, }, diff --git a/apps/site/src/app/compare/supabase/_sections.tsx b/apps/site/src/app/compare/supabase/_sections.tsx index 326c69cee7..8118281033 100644 --- a/apps/site/src/app/compare/supabase/_sections.tsx +++ b/apps/site/src/app/compare/supabase/_sections.tsx @@ -67,7 +67,10 @@ const SOURCES = [ product: "Supabase", links: [ { label: "Supabase pricing", href: "https://supabase.com/pricing" }, - { label: "Spend Cap", href: "https://supabase.com/docs/guides/platform/spend-cap" }, + { + 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", diff --git a/apps/site/src/app/globals.css b/apps/site/src/app/globals.css index 91c4646889..d0251626af 100644 --- a/apps/site/src/app/globals.css +++ b/apps/site/src/app/globals.css @@ -610,7 +610,6 @@ Unlayered so it beats Tailwind's base layer; disabled for reduced motion. */ html { scroll-behavior: smooth; - scroll-padding-top: 6rem; } @media (prefers-reduced-motion: reduce) { diff --git a/apps/site/src/components/answer-pages/answer-page.tsx b/apps/site/src/components/answer-pages/answer-page.tsx index 345c0302c9..eed12759d9 100644 --- a/apps/site/src/components/answer-pages/answer-page.tsx +++ b/apps/site/src/components/answer-pages/answer-page.tsx @@ -68,9 +68,9 @@ export function AnswerPage({ page }: { page: AnswerPageContent }) {

    {data.title}

    -

    +

    {renderFaqAnswer(data.summary)} -

    +
    By{" "} diff --git a/apps/site/src/lib/markdown/comparisons.test.tsx b/apps/site/src/lib/markdown/comparisons.test.tsx index fb6ae22f79..f8ed7b2a59 100644 --- a/apps/site/src/lib/markdown/comparisons.test.tsx +++ b/apps/site/src/lib/markdown/comparisons.test.tsx @@ -1172,7 +1172,8 @@ test("Supabase Markdown carries the byline, disclosure, and Sources on the share getComparisonStructuredData("supabase")["@graph"][0]?.headline, supabase.SUPABASE_TITLE, ); - assert.equal(getComparisonMetadata("supabase").openGraph?.title, supabase.SUPABASE_TITLE); + const openGraph = getComparisonMetadata("supabase").openGraph as { title?: unknown } | undefined; + assert.equal(openGraph?.title, supabase.SUPABASE_TITLE); const minutes = getSupabaseReadingStats().minutes; assert.ok( markdown.includes( diff --git a/apps/site/src/lib/markdown/comparisons.tsx b/apps/site/src/lib/markdown/comparisons.tsx index 3aea52cb12..8956494db7 100644 --- a/apps/site/src/lib/markdown/comparisons.tsx +++ b/apps/site/src/lib/markdown/comparisons.tsx @@ -35,6 +35,16 @@ export const comparisonContent = { supabase: getSupabasePageContent, } satisfies Record ReactNode>; +/** Last-checked date and modification time per comparison; every slug needs an entry. */ +const comparisonFreshness = { + convex: { checked: CONVEX_CHECKED, dateModified: CONVEX_UPDATED.iso }, + kysely: { checked: KYSELY_CHECKED, dateModified: KYSELY_UPDATED.iso }, + mongoose: { checked: MONGOOSE_CHECKED, dateModified: MONGOOSE_UPDATED.iso }, + neon: { checked: NEON_CHECKED, dateModified: NEON_UPDATED.iso }, + planetscale: { checked: PLANETSCALE_CHECKED, dateModified: PLANETSCALE_UPDATED.iso }, + supabase: { checked: SUPABASE_CHECKED, dateModified: SUPABASE_UPDATED.iso }, +} satisfies Record; + /** Register complete comparison renditions using the same trees as the HTML pages. */ export function getComparisonMarkdownPages(): Record { const index: MarkdownPage = { @@ -71,19 +81,7 @@ export function getComparisonMarkdownPages(): Record blocks.slice(2).join("\n\n"), - ...(entry.slug === "kysely" - ? { checked: KYSELY_CHECKED, dateModified: KYSELY_UPDATED.iso } - : entry.slug === "mongoose" - ? { checked: MONGOOSE_CHECKED, dateModified: MONGOOSE_UPDATED.iso } - : entry.slug === "convex" - ? { checked: CONVEX_CHECKED, dateModified: CONVEX_UPDATED.iso } - : entry.slug === "neon" - ? { checked: NEON_CHECKED, dateModified: NEON_UPDATED.iso } - : entry.slug === "planetscale" - ? { checked: PLANETSCALE_CHECKED, dateModified: PLANETSCALE_UPDATED.iso } - : entry.slug === "supabase" - ? { checked: SUPABASE_CHECKED, dateModified: SUPABASE_UPDATED.iso } - : {}), + ...comparisonFreshness[entry.slug], } satisfies MarkdownPage, ]; }), diff --git a/apps/site/src/lib/structured-data.ts b/apps/site/src/lib/structured-data.ts index ddf33b34d2..f806d20b37 100644 --- a/apps/site/src/lib/structured-data.ts +++ b/apps/site/src/lib/structured-data.ts @@ -24,7 +24,7 @@ function toPlainText(value: string): string { // the FAQPage JSON-LD must not inherit: links keep the anchor words, code // chips keep their contents, both drop their markup. Fences keep the code. .replace(/```(?:\w+)?\n?([\s\S]*?)```/g, " $1 ") - .replace(/\[((?:[^[\]\\`]|`[^`]*`)+)\]\((?:\/[^\s)]*|https?:\/\/[^\s)]+)\)/g, "$1") + .replace(/\[((?:[^[\]\\`]|`[^`]*`)+)\]\((?:\/[^\s)]*|#[^\s)]+|https?:\/\/[^\s)]+)\)/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace(/&quot;/g, '"') .replace(/&#39;|&apos;/g, "'")