codexmachina
registry/react-router-postgres-better-auth-social

Social network on React Router v8, Postgres (Neon) and Better Auth

verified 2026-07-15postgres3.4.9better-auth1.6.23react-router8.2.0@neondatabase/serverless1.1.0

Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify

request path
Browserrequest
fetch
React Router v8routing + proxy
verify
Better Authsession
query
Postgres (Neon)pooled

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.

Better Auth

Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).

Social network

Social network — public profiles with @-handles, a directed follow graph, an authored-post feed, and per-post like edges.

Setup

bun add react-router react react-dom drizzle-orm postgres better-auth
DATABASE_URLNeon pooled (-pooler) connection string
BETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`
BETTER_AUTH_URLyour app's base URL

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 });
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const session = pgTable("session", {
  id: text("id").primaryKey(),
  expiresAt: timestamp("expires_at").notNull(),
  token: text("token").notNull().unique(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
});

export const account = pgTable("account", {
  id: text("id").primaryKey(),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  password: text("password"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
});

export const verification = pgTable("verification", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Better Auth instance (self-hosted, React Router v8).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// app/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
import { db } from "./db";
import * as authSchema from "@/db/auth-schema";

export const auth = betterAuth({
  // Drizzle adapter over the shared client. The schema is passed HERE (not into
  // drizzle()) — it is the only consumer that needs it, so the db client stays
  // schema-less. ponytail: do NOT enable experimental.joins; it is the one option
  // that would make the adapter reach into db._.fullSchema.
  // ponytail: usePlural stays false (the default) — with it true Better Auth would
  // look for `sessions`/`accounts` and could bind to the analytics/ledger app-type tables.
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
  // ponytail: email+password is the shortest real auth that works out of
  // the box — add socialProviders / plugins here when the app needs them.
  emailAndPassword: { enabled: true },
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

export type Session = typeof auth.$Infer.Session;
// Better Auth mounted as a React Router resource route (a route module with NO
// default component). auth.handler is framework-agnostic — (Request) => Response —
// so the GET loader and the POST/etc. action both delegate straight to it. The
// `$` splat catches every /api/auth/* sub-path Better Auth routes internally.
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { auth } from "@/lib/auth";

export function loader({ request }: LoaderFunctionArgs) {
  return auth.handler(request);
}

export function action({ request }: ActionFunctionArgs) {
  return auth.handler(request);
}
// Session gate — the RR7 replacement for Next's Edge proxy. Protected loaders
// call `await requireAuth(request)`. Unlike the proxy's cookie-existence check,
// this does the REAL server-side validation via auth.api.getSession, then throws
// a redirect Response (React Router short-circuits the loader on a thrown Response)
// to bounce logged-out users before the protected data ever loads.
import { redirect } from "react-router";
import { auth } from "@/lib/auth";

export async function requireAuth(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) {
    throw redirect("/sign-in");
  }
  return session;
}

Social network schema: profiles, follow graph, posts & likes

Profiles & handles

one public profile per Better Auth user, with a unique @-handle and optional bio/avatar

Follows: the directed user graph

directed follower→following edges with a composite unique that prevents duplicate follows

Posts & the feed

authored feed posts keyed to a Better Auth user, indexed per-author for profile timeline queries

Post likes

post↔user like edges with a composite unique enforcing one like per user per post

src/db/schema.ts
// === file: app/db/schema.ts ===
import { relations } from "drizzle-orm";
import {
  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";

/** Public profile, one per Better Auth user. handle is the @-name in the URL. */
export const profiles = pgTable(
  "profiles",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // Better Auth's user.id is text — match it, don't recast. One profile per user.
    userId: text("user_id")
      .notNull()
      .unique()
      .references(() => user.id, { onDelete: "cascade" }),
    handle: text("handle").notNull().unique(),
    bio: text("bio"),
    avatarUrl: text("avatar_url"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_profile_handle").on(t.handle)],
);

/** Directed follow edge: follower -> following. Both ends are Better Auth users. */
export const follows = pgTable(
  "follows",
  {
    // Better Auth's user.id is text — match it, don't recast.
    followerId: text("follower_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    followingId: text("following_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    primaryKey({ columns: [t.followerId, t.followingId] }),
    // The edge is the identity — you can't follow the same user twice.
    unique("follows_follower_following_unique").on(
      t.followerId,
      t.followingId,
    ),
    // Drives the "who follows this user" (followers list) query.
    index("idx_follow_following").on(t.followingId),
  ],
);

/** Authored post in the feed. */
export const posts = pgTable(
  "posts",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // 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(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    // Drives the per-author profile timeline query.
    index("idx_post_author").on(t.authorId),
  ],
);

/** post <-> user like edge. The composite unique is the like identity. */
export const postLikes = pgTable(
  "post_likes",
  {
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    // Better Auth's user.id is text — match it, don't recast.
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    primaryKey({ columns: [t.postId, t.userId] }),
    // One like per user per post.
    unique("post_likes_post_user_unique").on(t.postId, t.userId),
    index("idx_post_likes_user").on(t.userId),
  ],
);

export const profilesRelations = relations(profiles, ({ one }) => ({
  user: one(user, { fields: [profiles.userId], references: [user.id] }),
}));

export const followsRelations = relations(follows, ({ one }) => ({
  follower: one(user, {
    fields: [follows.followerId],
    references: [user.id],
  }),
  following: one(user, {
    fields: [follows.followingId],
    references: [user.id],
  }),
}));

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

export const postLikesRelations = relations(postLikes, ({ one }) => ({
  post: one(posts, {
    fields: [postLikes.postId],
    references: [posts.id],
  }),
  user: one(user, { fields: [postLikes.userId], 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);
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Long-running process: a real, reused pool. Still prepare:false on the pooled endpoint.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 10, idle_timeout: 20 });
export const db = drizzle(sql);
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

// Edge/Workers have NO TCP sockets, so postgres-js cannot run here. Neon's HTTP
// driver speaks Postgres over fetch — the only client that works on Workers.
export const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);

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

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

Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.

note

Both the follow edge (follower_id + following_id composite PK + unique) and the like edge (post_id + user_id composite PK + unique) carry explicit unique constraints — the constraint is the identity, not just a performance index.

note

Better Auth's user.id is text, not uuid; all four tables match that type on their FK columns rather than recasting, so joins never cross a type boundary.