mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
About 95% of the 404s served on supabase.com are App Router RSC prefetches (`?_rsc=`) that www `<Link>`s fire at paths another zone serves: `/docs`, `/dashboard`, `/library`, and the footer's `humans.txt`, `lawyers.txt` and `security.txt`. Next.js can't prefetch or client-navigate across multi-zone boundaries, so each prefetch 404s even though the link itself works. I turned every www link into another zone into a plain `<a>` and added a lint rule so new ones stay that way. **Changed:** - **Cross-zone links are plain anchors**: links that always leave www (literal `/docs`, `/dashboard` and `.txt` hrefs, absolute `https://supabase.com/dashboard` URLs, the `getDashboardCtaHref` CTAs) render `<a>`. Renderers whose href comes from data (nav, footer, plan and add-on CTAs, product cards) branch on `isCrossZoneHref`, which reads the zone list from `lib/rewrites.js`. In-zone links stay `<Link>` and keep prefetching. - **New literal links can't regress**: `www/no-cross-zone-link` errors on a `next/link` `<Link>` whose literal or template href points at another zone. It evaluates `lib/rewrites.js` as production, so `/docs` counts in every environment. - **Click tracking survives the full navigation**: `sign_in_button_clicked`, `start_project_button_clicked` and `www_pricing_plan_cta_clicked` now send with `keepalive`, like `sign_in_submitted` already did, so an immediate page load can't cancel them. The mobile nav Sign in and Start your project buttons used `legacyBehavior`, which never called their `onClick`: PostHog has no `Mobile Nav` location for either event in the last 30 days. Those clicks report from this PR on. - **Typecheck no longer crashes**: the functions page's default export inferred a type through `RealtimeLogs`'s unexported `Props`, which makes the native TypeScript compiler panic during `tsc --noEmit`. I exported `Props`. **Note:** the lint rule only sees literal hrefs. A new renderer whose href comes from data needs its own `isCrossZoneHref` branch, and review is the only check on that. ## To test `/docs` is only rewritten on production and absolute `https://supabase.com/...` links are cross-origin on a preview, so the preview proves the relative non-docs cases (`/dashboard*`, `/library`, the footer .txt files). `/docs/...` prefetches still appear on the preview because it has no docs rewrite. Tested on Vercel preview: - [x] Open `/` with the network tab filtered to `_rsc` and scroll to the footer: no requests for `/dashboard*`, `/library`, `/design-system`, `/kb`, `/evals`, `/humans.txt`, `/lawyers.txt` or `/.well-known/security.txt`, while in-zone ones such as `/pricing`, `/features` and `/blog` still appear - [x] Same check on `/pricing`, `/auth`, `/database`, `/storage`, `/realtime`, `/edge-functions`, `/blog` and a blog post: no `_rsc` requests to `/dashboard*`, `/library` or the footer .txt files - [x] Open the Developers dropdown on desktop and the mobile menu at 390px: no new `_rsc` requests to `/dashboard*` or `/library` - [x] Click header Docs, footer Humans.txt, the hero Start your project button and the pricing Free plan button: each lands where it did before (`/docs`, `/humans.txt` text, `https://supabase.com/dashboard/sign-up`, `https://supabase.com/dashboard/new?plan=free`). Signed out, the Free plan button lands on the dashboard sign-in with `plan=free&returnTo=%2Fnew` - [x] Click the hero Start your project button: the `/platform/telemetry/event` POST with `start_project_button_clicked` completes with a 2xx after the page starts navigating. 201 with the navigation held; on the real navigation the event still reached staging PostHog - [x] At 390px, open the mobile menu and click Sign in: a `/platform/telemetry/event` POST with `sign_in_button_clicked` and `buttonLocation: "Mobile Nav"` fires. Start your project in the same menu also sends `start_project_button_clicked` with `buttonLocation: "Mobile Nav"` - [ ] Signed in, load `/`: no `/dashboard/projects?_rsc=` request (not run: the preview origin has no signed-in session) - [x] Open the desktop Product dropdown and the Product section of the 390px mobile menu: Compute shows its Private Alpha badge and the other products show none (checks the master merge into `MenuItem`) After deploy, `/` and `/pricing` on supabase.com show no `_rsc` requests to `/docs*`, `/dashboard*` or `/library`. After a full day, the share of supabase.com 404s carrying `_rsc=` should drop from about 95% to under 10%, and `sign_in_button_clicked` and `start_project_button_clicked` should start showing a `Mobile Nav` location in PostHog. ## Linear - fixes GROWTH-1294
201 lines
7.9 KiB
TypeScript
201 lines
7.9 KiB
TypeScript
import Examples from 'data/Examples'
|
|
import Link from 'next/link'
|
|
import { cn } from 'ui'
|
|
|
|
const EXAMPLE_LOGO: Record<string, { src: string; invert?: boolean }> = {
|
|
'Stripe Subscriptions Starter': { src: '/images/logos/frameworks/stripe.svg' },
|
|
'Next.js Starter': { src: '/images/logos/frameworks/nextjs.svg', invert: true },
|
|
'AI Chatbot': { src: '/images/logos/frameworks/openai.svg', invert: true },
|
|
'LangChain + Next.js Starter': { src: '/images/logos/frameworks/langchain.svg' },
|
|
'Flutter User Management': { src: '/images/logos/frameworks/flutter.svg' },
|
|
'Expo React Native Starter': { src: '/images/logos/frameworks/expo.svg', invert: true },
|
|
}
|
|
|
|
function SkeletonBar({ className }: { className?: string }) {
|
|
return <div className={cn(`rounded bg-foreground-muted/10`, className)} />
|
|
}
|
|
|
|
function StripeSkeleton() {
|
|
return (
|
|
<div
|
|
aria-label="Pricing page with subscription tiers"
|
|
className="flex flex-col items-center justify-center gap-3 p-5 w-full"
|
|
>
|
|
<div className="flex -mb-8">
|
|
<div className="w-24 aspect-[9/16] border flex flex-col items-center">
|
|
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
|
<SkeletonBar className="w-8 h-2.5" />
|
|
<SkeletonBar className="w-16 h-2.5" />
|
|
</div>
|
|
|
|
<SkeletonBar className="w-20 aspect-square mt-4" />
|
|
</div>
|
|
|
|
<div className="w-24 aspect-[9/16] border flex flex-col items-center border-l-0 bg-[#635BFF]/5">
|
|
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
|
<SkeletonBar className="w-8 h-2.5 bg-purple-800" />
|
|
<SkeletonBar className="w-16 h-2.5 bg-purple-900" />
|
|
</div>
|
|
|
|
<SkeletonBar className="w-20 aspect-square mt-4 dark:bg-muted" />
|
|
</div>
|
|
|
|
<div className="w-24 aspect-[9/16] border flex flex-col items-center border-l-0">
|
|
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
|
<SkeletonBar className="w-8 h-2.5" />
|
|
<SkeletonBar className="w-16 h-2.5" />
|
|
</div>
|
|
|
|
<SkeletonBar className="w-20 aspect-square mt-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NextjsSkeleton() {
|
|
return (
|
|
<div
|
|
aria-label="Dashboard with sidebar navigation and data grid"
|
|
className="flex w-full h-full"
|
|
>
|
|
{/* Sidebar */}
|
|
<div className="w-24 border-r border-foreground-muted/10 flex flex-col gap-2 p-3 pt-3">
|
|
<SkeletonBar className="h-3 w-3 rounded-sm bg-[#0070F3]" />
|
|
<div className="flex flex-col gap-1.5 mt-3">
|
|
<SkeletonBar className="h-2 w-full bg-muted" />
|
|
<SkeletonBar className="h-2 w-14" />
|
|
<SkeletonBar className="h-2 w-full" />
|
|
<SkeletonBar className="h-2 w-10" />
|
|
</div>
|
|
</div>
|
|
{/* Main */}
|
|
<div className="flex-1 flex flex-col gap-2 p-3">
|
|
<SkeletonBar className="h-2.5 w-16 bg-muted" />
|
|
{/* 3x4 data grid */}
|
|
<div className="grid grid-cols-4 grid-rows-3 gap-1.5 flex-1">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'rounded-sm bg-foreground-muted/10 min-h-[40px]',
|
|
[1, 6, 9].includes(i) && 'bg-muted/70'
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SKELETON_CONTENT: Record<string, () => React.JSX.Element> = {
|
|
'Stripe Subscriptions Starter': StripeSkeleton,
|
|
'Next.js Starter': NextjsSkeleton,
|
|
}
|
|
|
|
export function BuiltWithSupabaseSection() {
|
|
const featuredExamples = Examples.slice(0, 2)
|
|
const gridExamples = Examples.slice(2, 6)
|
|
|
|
return (
|
|
<div className="py-24 flex flex-col gap-16">
|
|
{/* Header row */}
|
|
<div className="mx-auto max-w-(--container-max-w,75rem) w-full px-6">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
|
Kickstart your next project <br />
|
|
<span className="text-foreground">with production ready templates</span>
|
|
</h3>
|
|
<a
|
|
href="/docs/guides/examples"
|
|
className="text-sm text-foreground-light hover:text-foreground underline"
|
|
>
|
|
View all examples
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-auto max-w-(--container-max-w,75rem) px-6 w-full">
|
|
{/* Featured row - 2 large cells */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{featuredExamples.map((example) => {
|
|
const logo = EXAMPLE_LOGO[example.title]
|
|
const Skeleton = SKELETON_CONTENT[example.title]
|
|
return (
|
|
<Link
|
|
key={example.title}
|
|
href={example.repo_url ?? '#'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden hover:bg-surface-100 transition-colors"
|
|
>
|
|
<div className="px-6 py-5 flex flex-col gap-2.5">
|
|
{logo && (
|
|
<img
|
|
src={logo.src}
|
|
alt=""
|
|
className={`h-5 w-fit object-contain ${logo.invert ? ' dark:invert' : ''}`}
|
|
/>
|
|
)}
|
|
<div>
|
|
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
|
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
|
{example.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center px-6 pt-4 pb-0 overflow-hidden">
|
|
{/* Browser frame */}
|
|
<div className="w-full rounded-t-lg border border-b-0 border-border-stronger/50 shadow-lg -mb-[10%] overflow-hidden">
|
|
{/* Title bar */}
|
|
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border bg-background-200">
|
|
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
|
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
|
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
|
</div>
|
|
{/* Skeleton content */}
|
|
<div className="h-[240px] flex items-center justify-center bg-background">
|
|
{Skeleton ? <Skeleton /> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Grid row - 4 smaller cells */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
|
|
{gridExamples.map((example) => {
|
|
const logo = EXAMPLE_LOGO[example.title]
|
|
return (
|
|
<Link
|
|
key={example.title}
|
|
href={example.repo_url ?? '#'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group flex flex-col bg-surface-75 border border-border rounded-lg px-6 py-10 hover:bg-surface-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
{logo && (
|
|
<img
|
|
src={logo.src}
|
|
alt=""
|
|
className={`h-5 w-5 object-contain${logo.invert ? ' dark:invert' : ''}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
|
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
|
{example.description}
|
|
</p>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|