codexmachina
registry/react-router-postgres-supabase-blog-cms

Blog / CMS on React Router v8, Postgres (Neon) and Supabase Auth

Blog / CMS: authored posts with a draft→published→archived workflow, slug-keyed taxonomy (categories + tags via join), and moderated reader comments.

51 files, 3 tables and 154 lines of schema, verified 2026-08-23 on React Router v8, Postgres (Neon) and Supabase Auth.

Download .tar.gzverified 2026-08-23How we verify
React Router v8 content starter: the editor, rendered from the verified UI
Rendered from the verified starter · the editor
15 pinned upstream versions
clsx2.1.1vaul1.1.2shadcn4.16.1sonner2.0.7postgres3.4.9radix-ui1.6.7recharts3.10.1lucide-react1.28.0react-router8.3.0@supabase/ssr0.12.4tailwind-merge3.6.0tw-animate-css1.4.0@tanstack/react-table8.21.3@neondatabase/serverless1.1.0class-variance-authority0.7.1
Browserrequest
fetch
React Router v8routing + proxy
verify
Supabase Authsession
query
Postgres (Neon)pooled

request path. session validation runs in server components and route handlers, not at the edge

What you're getting

React Router v8

React Router v8 (framework mode): SSR, config/file routes under app/, loaders/actions, and resource routes for API endpoints.

Postgres (Neon)

Postgres on Neon via Drizzle ORM and the postgres-js driver.

Supabase Auth

Supabase Auth: hosted identity (GoTrue) via the @supabase/ssr cookie client; email+password + OAuth.

Blog / CMS

Blog / CMS: authored posts with a draft→published→archived workflow, slug-keyed taxonomy (categories + tags via join), and moderated reader comments.

Setup

bun add react-router react react-dom drizzle-orm postgres @supabase/ssr @supabase/supabase-js
DATABASE_URLNeon pooled (-pooler) connection string
NEXT_PUBLIC_SUPABASE_URLNext: your Supabase project URL (RR: VITE_SUPABASE_URL · Nuxt: NUXT_PUBLIC_SUPABASE_URL)
NEXT_PUBLIC_SUPABASE_ANON_KEYNext: the project's anon/public key (RR: VITE_SUPABASE_ANON_KEY · Nuxt: NUXT_PUBLIC_SUPABASE_ANON_KEY)
SUPABASE_URLReact Router server-side (loaders): same project URL, read off process.env — never inlined into the client bundle
SUPABASE_ANON_KEYReact Router server-side: same anon key

Apply the schema with bunx drizzle-kit push

Initialization

Database client

app/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Neon pooled endpoint = PgBouncer transaction mode → prepared statements off.
// ponytail: single module-level client; the serverless runtime + PgBouncer do
// the pooling, so no custom pool/globalThis singleton dance needed.
const client = postgres(process.env.DATABASE_URL!, { prepare: false });

export const db = drizzle({ client });

Blog / CMS schema: posts, taxonomy & moderated comments

5 tables, 24 columns and 15 indexes and constraints, applied to a live Postgres (Neon) and asserted to materialize.

posts8 columns · 4 indexed
categories4 columns · 3 indexed
tags4 columns · 3 indexed
post_tags2 columns · 3 indexed
comments6 columns · 2 indexed

What this schema is built to answer

The public post index, newest first

idx_post_status_published is a composite on (status, published_at): equality on 'published' then an ordered walk of the timestamp, so the listing needs no sort step.

Serving a single post by slug

posts.slug carries a unique constraint, making the lookup a single-row index hit; because comments.post_id and post_tags.post_id reference posts.id instead, editing a slug touches no foreign key.

A tag archive page

tags.slug is unique with idx_tag_slug behind the URL, and idx_post_tags_tag on post_tags.tag_id collects every post carrying that tag — the reverse direction of the join's (post_id, tag_id) primary key.

Re-saving a post's tag set

post_tags has no surrogate key: (post_id, tag_id) is the primary key, so writing the tag set is an idempotent insert-on-conflict, and the leading post_id lists a post's tags without a second index.

The comment moderation queue

comments.status defaults to 'pending' under comments_status_check ('pending', 'approved', 'spam'), and idx_comment_post fetches a post's thread; the approved-only filter is applied over that index, which is keyed on post_id alone.

Posts & publishing workflow

the posts table with slug-unique constraint, author FK into Better Auth's user, and a status/publishedAt pair that drives the published feed index

Taxonomy: categories & tags

the slug-keyed categories and tags tables plus the post_tags join that attaches many tags to many posts

Comments & moderation

the comments table hanging off posts with a pending→approved→spam moderation status and per-post thread index

src/db/schema.ts
// === file: app/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  check,
  index,
  pgTable,
  primaryKey,
  text,
  timestamp,
  unique,
  uuid,
} from "drizzle-orm/pg-core";
// Better Auth owns identity; we only reference its `user` table by id.
import { user } from "./auth-schema";

export type PostStatus = "draft" | "published" | "archived";
export type CommentStatus = "pending" | "approved" | "spam";

/** Authored content with a draft -> published -> archived workflow. */
export const posts = pgTable(
  "posts",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    slug: text("slug").notNull().unique(),
    title: text("title").notNull(),
    body: text("body").notNull(),
    // Better Auth's user.id is text — match it, don't recast.
    authorId: text("author_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    status: text("status").$type<PostStatus>().notNull().default("draft"),
    // Set when status flips to 'published'; null while draft/archived.
    publishedAt: timestamp("published_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index("idx_post_author").on(t.authorId),
    // Drives the public "latest published posts" feed query.
    index("idx_post_status_published").on(t.status, t.publishedAt),
    check(
      "posts_status_check",
      sql`${t.status} in ('draft','published','archived')`,
    ),
  ],
);

/** Editorial taxonomy: one category per post grouping (seed/editor-managed). */
export const categories = pgTable(
  "categories",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    slug: text("slug").notNull().unique(),
    name: text("name").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_category_slug").on(t.slug)],
);

/** Free-form taxonomy attached to posts many-to-many via post_tags. */
export const tags = pgTable(
  "tags",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    slug: text("slug").notNull().unique(),
    name: text("name").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_tag_slug").on(t.slug)],
);

/** post <-> tag join. The composite unique is the tagging identity. */
export const postTags = pgTable(
  "post_tags",
  {
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    tagId: uuid("tag_id")
      .notNull()
      .references(() => tags.id, { onDelete: "cascade" }),
  },
  (t) => [
    primaryKey({ columns: [t.postId, t.tagId] }),
    unique("post_tags_post_tag_unique").on(t.postId, t.tagId),
    index("idx_post_tags_tag").on(t.tagId),
  ],
);

/** Reader comments on posts with a moderation workflow. */
export const comments = pgTable(
  "comments",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    // Better Auth's user.id is text — match it, don't recast.
    authorId: text("author_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    body: text("body").notNull(),
    status: text("status").$type<CommentStatus>().notNull().default("pending"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // Drives the per-post comment thread query.
    index("idx_comment_post").on(t.postId),
    check(
      "comments_status_check",
      sql`${t.status} in ('pending','approved','spam')`,
    ),
  ],
);

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(user, { fields: [posts.authorId], references: [user.id] }),
  postTags: many(postTags),
  comments: many(comments),
}));

export const categoriesRelations = relations(categories, ({ many }) => ({
  posts: many(posts),
}));

export const tagsRelations = relations(tags, ({ many }) => ({
  postTags: many(postTags),
}));

export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, {
    fields: [postTags.postId],
    references: [posts.id],
  }),
  tag: one(tags, {
    fields: [postTags.tagId],
    references: [tags.id],
  }),
}));

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
  author: one(user, { fields: [comments.authorId], references: [user.id] }),
}));

Deploy targets

✓ The right DB client for where you deploy: load-tested with concurrent queries against a live database. Edge needs the HTTP driver (no TCP); serverless needs a tiny pool.
src/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Serverless: one connection per (short-lived) instance; Neon's pooler multiplexes.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 1 });
export const db = drizzle(sql);

The app UI

A working auth flow and a protected app shell, type-checked against the same verified wiring above. This is what codexmachina create scaffolds on top of the official React Router v8 starter.
app/components/nav-user.tsx
import { useEffect, useState } from "react"
import { useNavigate } from "react-router"
import type { User } from "@supabase/supabase-js"

import { createClient } from "@/lib/supabase/client"
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  useSidebar,
} from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
import { EllipsisVerticalIcon, CircleUserRoundIcon, CreditCardIcon, BellIcon, LogOutIcon } from "lucide-react"

// React Router variant of the sidebar-footer user menu — the SAME DropdownMenu/SidebarMenu/Avatar
// shell as the other RR auths, wired to the browser Supabase client (getUser + onAuthStateChange)
// with RR's useNavigate replacing next/navigation's useRouter.
export function NavUser() {
  const { isMobile } = useSidebar()
  const navigate = useNavigate()
  const [supabase] = useState(() => createClient())
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    supabase.auth.getUser().then(({ data }) => {
      setUser(data.user)
      setLoading(false)
    })
    const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null)
    })
    return () => sub.subscription.unsubscribe()
  }, [supabase])

  // Loading state: a skeleton shaped like the user row, so the sidebar footer doesn't pop in.
  if (loading) {
    return (
      <SidebarMenu>
        <SidebarMenuItem>
          <div className="flex items-center gap-2 p-2">
            <Skeleton className="size-8 rounded-lg" />
            <Skeleton className="h-4 w-28 rounded-md" />
          </div>
        </SidebarMenuItem>
      </SidebarMenu>
    )
  }
  if (!user) return null

  const email = user.email ?? ""
  const initials = email.slice(0, 2).toUpperCase()
  const avatarUrl = typeof user.user_metadata?.avatar_url === "string" ? user.user_metadata.avatar_url : undefined

  async function handleSignOut() {
    await supabase.auth.signOut()
    navigate("/sign-in")
  }

  return (
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <SidebarMenuButton
              size="lg"
              className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
            >
              <Avatar className="h-8 w-8 rounded-lg grayscale">
                <AvatarImage src={avatarUrl} alt={email} />
                <AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
              </Avatar>
              <div className="grid flex-1 text-left text-sm leading-tight">
                <span className="truncate font-medium">{email}</span>
              </div>
              <EllipsisVerticalIcon className="ml-auto size-4" />
            </SidebarMenuButton>
          </DropdownMenuTrigger>
          <DropdownMenuContent
            className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
            side={isMobile ? "bottom" : "right"}
            align="end"
            sideOffset={4}
          >
            <DropdownMenuLabel className="p-0 font-normal">
              <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
                <Avatar className="h-8 w-8 rounded-lg">
                  <AvatarImage src={avatarUrl} alt={email} />
                  <AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
                </Avatar>
                <div className="grid flex-1 text-left text-sm leading-tight">
                  <span className="truncate font-medium">{email}</span>
                </div>
              </div>
            </DropdownMenuLabel>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <CircleUserRoundIcon />
                Account
              </DropdownMenuItem>
              <DropdownMenuItem>
                <CreditCardIcon />
                Billing
              </DropdownMenuItem>
              <DropdownMenuItem>
                <BellIcon />
                Notifications
              </DropdownMenuItem>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuItem onClick={handleSignOut}>
              <LogOutIcon />
              Log out
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  )
}

Decisions and compatibility

note

Framework mode (not data/library mode): routes live under app/, declared in app/routes.ts. API endpoints are resource routes (a route module exporting loader/action but no default component).

note

Data flows through loaders (run on the server before render) and actions (mutations); components read it with useLoaderData / useActionData. There are no React Server Components — every server-rendered route is a loader plus a client component.

note

Auth gates in the loader, not in middleware: a protected route's loader calls requireAuth(request), which throws a redirect Response that React Router short-circuits on — so a logged-out user never reaches the protected data or renders the page.

note

The `@/` import alias maps to app/ (this framework's source root), so shared modules like @/lib/auth resolve under app/ — the one path prefix that differs from Next's src/, which is why the auth slice's mount code is framework-specific.

note

prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.

note

Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.

note

Hosted: Supabase owns identity in its managed auth.users. This stack emits a LOCAL `user` mirror (db/auth-schema.ts) so app-type schemas can foreign-key `user` directly — keep it in sync with a Supabase trigger on auth.users (insert/update → public.user). The drizzle migration only owns the mirror table's shape, not the trigger.

note

Sessions are cookie-based (@supabase/ssr): the proxy refreshes them on every request; Server Components read the user via supabase.auth.getUser().

note

post_tags is keyed by a composite primary key on (post_id, tag_id) — the tagging identity used by application-level upserts. (Postgres also carries a redundant explicit unique on the same columns; MySQL relies on the composite PK alone.)

note

Comments default to 'pending' and require explicit promotion to 'approved'; the CHECK on both posts and comments uses text + CHECK rather than pgEnum so new statuses ship without an ALTER TYPE migration.

How this stack fits together

Supabase Auth owns its identity tables in the same database as posts, categories, tags, post_tags and comments, so the foreign keys reference the local user row directly and a delete cascades through them. No mirror, no webhook, and no window where the two stores disagree.

Postgres (Neon) stores those surrogate keys as uuid, so every foreign key across the 5 tables and 24 columns below is a uuid column. The migration was applied to a live Postgres (Neon) and the tables asserted, not just type-checked.

Blog / CMS

The publishing workflow is not a side field on this schema — it is the column the read path turns on. posts.status is text constrained by posts_status_check to 'draft', 'published' or 'archived', and idx_post_status_published indexes (status, published_at) in that order: equality on the status, then an ordered walk of the timestamp. The public index page therefore arrives newest-first with no sort step, the one read here where ordering comes free from the index, and the reason those two columns share a composite instead of sitting in separate indexes. published_at is deliberately not created_at. A draft has a creation time and a null published_at; the timestamp is written when the post goes live, which also lets an editor back-date or schedule by setting it directly.

Nothing at the database level ties the pair together — a row can be 'published' with a null published_at and the CHECK will accept it — so that invariant belongs to whatever handles the transition. Identity is doubled on purpose. slug is unique and is what the URL carries, but comments.post_id and post_tags.post_id both reference posts.id, the generated surrogate key. Renaming a slug rewrites one column and breaks no reference. Taxonomy comes in two shapes and only one of them is wired to posts. post_tags is the real join: a composite primary key on (post_id, tag_id), which makes re-saving a post's tag set an idempotent insert-on-conflict, with idx_post_tags_tag inverting it for a tag archive.

categories is the single-valued half: posts.category_id is a nullable FK onto it with idx_post_category behind it, and ON DELETE set null, so retiring a category unfiles its posts instead of deleting them. Comments land at 'pending' by default, with 'approved' and 'spam' as the other two states comments_status_check allows, so a comment stays invisible until someone promotes it. idx_comment_post keys on post_id alone, so a thread fetch is an index range and the approved-only filter is applied over the rows it returns.

React Router v8

React Router v8 in framework mode puts everything under app/, and `@/` maps to that root instead of Next's src/ — the one prefix that differs, which is why shared modules like @/lib/auth and @/db/schema stay byte-identical to their Next counterparts. initCode writes app/lib/db.ts, then the auth fragment adds app/lib/auth.ts, the resource route app/routes/api.auth.$.ts, and app/lib/require-auth.ts. The route table itself is app/routes.ts: routes are declared configuration, and a file becomes a URL because that table says so. There are no React Server Components here. Every server-rendered route is a loader plus an ordinary client component: the loader runs on the server before render, the component reads its result with useLoaderData, and mutations go through an action read back with useActionData.

An API endpoint is the same module minus the default export — a resource route, named with the flat dotted convention (app/routes/api.auth.$.ts for the auth splat, app/routes/webhooks.polar.ts for a webhook POST). Auth gates in the loader rather than in a middleware layer. A protected route awaits requireAuth(request) from app/lib/require-auth.ts, which calls auth.api.getSession({ headers: request.headers }) — a real server-side validation, not a cookie peek — and throws redirect("/sign-in") when there is no session. React Router treats a thrown Response as the route's outcome, so the loader short-circuits and neither the protected query nor the component ever runs. The trade that follows: there is no matcher array to widen and no edge tier to keep honest, but protection is per-route discipline.

A new route is protected because its loader calls requireAuth; forget the call and the page is public. In return, every gate sits one function call away from the data it guards, the session is already in hand when the loader queries db, and the same request-in / Response-out contract covers pages, API endpoints and the auth mount alike.

Postgres (Neon)

Postgres here is Neon reached through postgres-js, with Drizzle's pg-core dialect on top: drizzle({ client }) over a single module-level postgres(DATABASE_URL, { prepare: false }). That flag is not a preference. Neon's pooled (-pooler) endpoint is PgBouncer in transaction mode, where a backend is handed to a different session between statements, so server-side prepared statements break across the pool — and the same constraint is why this axis pairs with Drizzle rather than Prisma. One client per module is enough: PgBouncer and the runtime do the pooling, so there is no globalThis singleton dance. The schemas built on this dialect make three recurring type decisions. Primary keys are uuid(...).primaryKey().defaultRandom(), so ids come from the database. Timestamps are timestamp(..., { withTimezone: true }).defaultNow() — timestamptz, an absolute instant.

Closed value sets are text plus a CHECK constraint rather than pgEnum, so shipping a new role or subscription status is an ordinary constraint change instead of an ALTER TYPE migration. Counters are bigint({ mode: "number" }), and Better Auth's text user.id is referenced as text by the app tables rather than recast. Operationally, transaction-mode pooling forbids anything that spans statements on one backend: LISTEN/NOTIFY, session-scoped SET, advisory-lock sessions, WITH HOLD cursors. Those paths use Neon's direct endpoint instead. The connection client also changes with the deploy target — max: 1 per short-lived serverless instance, a real reused pool (max 10, idle_timeout 20) in a long-running Node process, and on Cloudflare Workers postgres-js is replaced outright by @neondatabase/serverless over HTTP, because Workers have no TCP sockets.

The capability that exists only on this side of the matrix is row-level security. Multi-tenant schemas ship ENABLE plus FORCE ROW LEVEL SECURITY with policies keyed on current_setting('app.current_org_id', true), which withTenant() sets per transaction — unset context yields no rows, so isolation fails closed inside the database rather than in application code. It requires a dedicated NOBYPASSRLS role: Neon's default neondb_owner carries BYPASSRLS, and connecting as it makes every policy silently inert.

Supabase Auth

Supabase Auth is a hosted service — GoTrue — that your app reaches over cookies rather than an SDK session object. Credentials and the canonical user records live in Supabase's managed auth.users schema. What lands in your own database is a mirror: db/auth-schema.ts declares user keyed by the Supabase auth uid (text; varchar(255) on MySQL) with email, full name, avatar URL and timestamps, so app-type schemas can foreign-key user exactly as they would under a self-hosted auth. Unlike the Clerk fragment, no sync webhook is emitted here, because Supabase's own trigger mechanism is the intended path: a trigger on auth.users writing into public.user lives in the Supabase project, not in the drizzle migration.

The migration owns the mirror's column shape and nothing else, so wiring that trigger is a step you take before those foreign keys mean anything. The mechanic that actually shapes this adapter is cookie refresh. @supabase/ssr rotates the auth token, and a rotated cookie only reaches the browser if something writes it onto the outgoing response — which is why every framework branch is built around the same getAll/setAll pair, wired to whatever that framework calls a cookie jar. Next's src/proxy.ts rebuilds the NextResponse inside setAll before calling getUser().

React Router has no middleware layer, so app/lib/supabase/server.ts constructs the client per request and returns { supabase, headers }, and the protected layout route attaches those headers to both exits — the redirect and the pass-through — so a refresh that happened during a guard is not lost. Nuxt's server/utils/supabase.ts binds the client to the h3 event and writes through setCookie, with a Nitro middleware doing the guard. Every decision point calls supabase.auth.getUser(), never getSession(): getSession reads whatever the cookie claims, getUser revalidates it against Supabase. Because a browser client is emitted alongside the server one, the auth screens are real forms calling supabase.auth.signInWithPassword rather than a hosted widget, and the sidebar's user menu subscribes to onAuthStateChange. Two consequences to plan around.

The anon key is public by design and is inlined into the client bundle under whichever prefix the framework demands (NEXT_PUBLIC_, VITE_, NUXT_PUBLIC_), so protection has to come from row-level security on Supabase's side, not from keeping the key quiet. And every guard is a network call to Supabase, not a local query — cheap, but not free, and on the path of every protected request.