agentleFS
SentryKnown publishergetsentry/vanguard/CLAUDE.md

vanguard

CLAUDE.md38 starsChanged 6 months agoApache-2.0View on GitHub

Vanguard is an internal blog-like platform inspired by PlanetScale's Beam. It's designed for creating permanence around timely internal moments — posts, categories, comments, reactions, and feeds — with Google OAuth (workspace-domain restricted) and basic email/password as fallback, plus role-based access (admin, canPostRestricted). Vanguard runs on a Vercel-native stack:

  • Reads credentials
  • Installs packages

The file

We split this CLAUDE.md into the rules it gives an agent

5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every data access helper ends in .server.ts and lives under app/models/.

    line 124
  2. Very specific:

    Client-visible env: must be prefixed VITE_ and read via import.meta.env.VITE_FOO — no more window.CONFIG injection.

    line 180
  3. Very specific:

    Use request.formData() (Web Standard, built into RR7) — never unstable_parseMultipartFormData.

    line 185
  4. Very specific:

    Test fixtures in app/lib/test/fixtures.ts use faker + a cuid suffix to guarantee uniqueness (don't remove the suffix — it prevents flaky …

    line 239
  5. Distinctive:

    Every auth-protected endpoint must have a test that hits it without a session and asserts the redirect

    line 241

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.

Project Overview

Vanguard is an internal blog-like platform inspired by PlanetScale's Beam. It's designed for creating permanence around timely internal moments — posts, categories, comments, reactions, and feeds — with Google OAuth (workspace-domain restricted) and basic email/password as fallback, plus role-based access (admin, canPostRestricted).

Tech Stack

Vanguard runs on a Vercel-native stack:

  • Framework: React Router v7 in framework mode (Remix's official successor, Vite-native)
  • Build pipeline: Vite, driven via vp (Vite Plus) or pnpm
  • ORM: Drizzle — drizzle-orm/neon-serverless (WebSocket-pooled @neondatabase/serverless) on Vercel, falling back to drizzle-orm/node-postgres (TCP pg) for local dev / tests / drizzle-kit migrate
  • Database: Neon Postgres (local Postgres in Docker for dev/tests)
  • File storage: Vercel Blob with a local filesystem fallback for offline dev
  • Hosting: Vercel (Preview Deployments wired to Neon branches)
  • Auth: remix-auth (still works on RR7) with a custom GoogleStrategy supporting the hd workspace-domain parameter
  • Observability: @sentry/react-router for server + client, sourcemaps uploaded by @sentry/vite-plugin
  • Background work: waitUntil() from @vercel/functions (with a local-dev polyfill) for fire-and-forget email / Slack notifications
  • Testing: Vitest + happy-dom, running against a real local Postgres (never mocked)
  • Package manager: pnpm (enforced) · Node: 24.x (LTS)
  • Lint / format / check: Vite+ (vp) — Oxlint + Oxfmt via vp lint, vp fmt, vp check

Key Development Commands

Setup

cp .env.example .env                 # Fill in DATABASE_URL, SESSION_SECRET, etc.
docker-compose up -d                  # Local Postgres at localhost:5432
pnpm install
pnpm db:migrate:dev                   # Apply Drizzle migrations to the dev DB
pnpm db:seed                          # Optional: demo user + category + 3 sample posts
pnpm dev                              # http://localhost:5173

Daily

pnpm dev                              # React Router dev server with HMR
pnpm build                            # Production build (react-router build)
pnpm start                            # Serve the production build locally
pnpm test                             # Drizzle migrate test DB → Vitest (run-once)
pnpm run test:vitest                  # Vitest only, run-once (skip migration)
pnpm run test:watch                   # Vitest in watch mode (skip migration)
pnpm typecheck                        # tsc -b (route types pre-generated by react-router)
vp lint                               # Oxlint
vp fmt --write                        # Oxfmt
vp check                              # fmt + lint in one pass (add --fix to auto-fix)

Database

pnpm db:migrate:dev                   # drizzle-kit migrate against DATABASE_URL
pnpm db:migrate:test                  # drizzle-kit migrate against .env.test DATABASE_URL
pnpm db:seed                          # ts-node drizzle/seed.ts
pnpm drizzle-kit generate             # Create a new migration from schema diff
pnpm drizzle-kit studio               # Optional: Drizzle Studio DB browser

CLI utilities

pnpm user create <email> <password> [--admin]
pnpm category create <slug> <name>

Architecture

Directory layout

app/
├── routes/              # RR7 file-based routes (dot notation, $param, _index, _layout)
│   ├── admin.*          # Admin-gated (requireAdmin)
│   ├── api.*            # JSON endpoints (comments, reactions, subscriptions)
│   ├── auth.*           # Login / Google OAuth flow
│   ├── p.$postId.*      # Post view + edit
│   ├── c.$categorySlug.*# Category views
│   └── u.$userEmail.*   # User profile views
├── models/              # Data access layer — *.server.ts only, Drizzle queries
├── db/
│   ├── schema.ts        # Drizzle table definitions (13 tables incl. _FeedToPost junction)
│   ├── relations.ts     # Drizzle relations for nested `with: {}` queries
│   └── client.ts        # db instance — neon-serverless on Vercel, node-postgres locally
├── services/
│   ├── auth.server.ts   # remix-auth authenticator (Form + Google strategies)
│   └── session.server.ts# createCookieSessionStorage wrapper
├── lib/
│   ├── upload-handler.ts# Vercel Blob put() + local fs fallback
│   ├── email.ts         # nodemailer SMTP
│   ├── slack.ts         # Webhook POST
│   ├── wait-until.ts    # @vercel/functions waitUntil wrapper with local-dev polyfill
│   └── google-auth.ts   # Custom GoogleStrategy extending OAuth2Strategy (hd param)
├── components/          # Shared UI components
└── routes.ts            # flatRoutes() from @react-router/fs-routes

drizzle/
├── migrations/          # Generated migrations (drizzle-kit generate)
├── seed.ts              # Seed script

test/
├── setup-test-env.ts    # Truncates all tables + seeds DEFAULT_USER per test
└── globals.d.ts         # Declares DefaultFixtures global type

vite.config.ts           # reactRouter + sentryVitePlugin + tsconfigPaths
react-router.config.ts   # RR7 config (ssr: true)

Routing conventions (RR7 — same as Remix)

  • Dots nest routes: admin.categories._index.tsx → /admin/categories
  • $param for dynamic segments: p.$postId._index.tsx → /p/:postId
  • Trailing _ opts OUT of parent layout: p.$postId_.edit.tsx is NOT nested under p.$postId
  • Leading _ = layout-only (no segment): _index.tsx → /, admin.tsx is the admin layout
  • $ catch-all: image-uploads.$.tsx → /image-uploads/*
  • [.] for literal dots in URLs: feeds.$feedId[.]xml.tsx → /feeds/:feedId.xml

Data layer

Every data access helper ends in .server.ts and lives under app/models/. They import db from ~/db/client and operators from drizzle-orm:

import { eq, and, ilike, sql } from "drizzle-orm";
import { db } from "~/db/client";
import { posts, users } from "~/db/schema";

Patterns:

  • Reads with relations: db.query.posts.findFirst({ with: { author: true, category: true } })
  • Reads with filters: db.select().from(posts).where(and(eq(posts.deleted, false), ...))
  • Writes: db.insert(users).values({...}).returning() — ALWAYS use .returning() to get inserted rows back
  • Upserts: .onConflictDoUpdate({ target: users.externalId, set: { email } })
  • Transactions: db.transaction(async (tx) => { ... }) — use tx inside, NOT db
  • Full-text search: raw SQL via sql\to_tsvector('english', ${posts.title}) @@ plainto_tsquery('english', ${query})``
  • Types: export inferred types from the schema: export type User = typeof users.$inferSelect; — DO NOT import types from anywhere Prisma-shaped.

Auth (MANDATORY rules)

Authentication is enforced per-route. Every protected route MUST enforce auth in BOTH the loader AND the action, and must have a test asserting that enforcement happens. This is the #1 rule of this codebase.

import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { requireUserId } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await requireUserId(request);
  // ...
}

export async function action({ request }: ActionFunctionArgs) {
  await requireUserId(request);
  // ...
}

Guard helpers in app/services/auth.server.ts:

  • getUserId(request) → string | undefined
  • getUser(request) → User | undefined
  • requireUserId(request) → string (throws redirect to /login?redirectTo=...)
  • requireUser(request) → User
  • requireAdmin(request) → User (throws redirect to /403 if not admin)

No context parameter. RR7 without a custom server has no getLoadContext() — guards parse the session cookie directly from the request.

Loader / action return shapes

  • Success: return a plain object. No json() helper. return { posts, user };
  • Errors with status: return Response.json({ error: "Bad request" }, { status: 400 }); or return data({ error }, { status: 400 }) from react-router (use data() when you need headers alongside).
  • Redirects: throw redirect("/login") or return redirect("/foo") — from react-router.

Config injection (client + server)

  • Server-only env: process.env.FOO — anywhere in *.server.ts or loaders/actions.
  • Client-visible env: must be prefixed VITE_ and read via import.meta.env.VITE_FOO — no more window.CONFIG injection.
  • app/config.tsx centralises the client-side config object.

File uploads

Use request.formData() (Web Standard, built into RR7) — never unstable_parseMultipartFormData. Call uploadFile() from app/lib/upload-handler.ts:

export async function action({ request }: ActionFunctionArgs) {
  await requireUserId(request);
  const formData = await request.formData();
  const file = formData.get("file");
  if (!(file instanceof File)) return Response.json({ error: "no file" }, { status: 400 });
  const { url } = await uploadFile({
    filename: file.name,
    buffer: Buffer.from(await file.arrayBuffer()),
  });
  return { url };
}

uploadFile() picks Vercel Blob if BLOB_READ_WRITE_TOKEN is set, otherwise falls back to os.tmpdir() + the image-uploads.$ serve route.

Fire-and-forget work (email / Slack)

Serverless functions terminate at response send, which kills un-awaited promises. Wrap background work with the local waitUntil helper:

import { waitUntil } from "~/lib/wait-until";

export async function announcePost(post: PostQueryType) {
  waitUntil(
    (async () => {
      await Promise.all(emails.map((e) => email.notify({ post, config: e })));
      await Promise.all(slacks.map((s) => slack.notify({ post, config: s })));
    })(),
  );
}

The helper calls Vercel's waitUntil in production; in local dev, it just lets the promise run with a console-level error handler.

Environment Variables

See .env.example for the full current list. High level:

CategoryVars
CoreDATABASE_URL, SESSION_SECRET, BASE_URL
AuthGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_HD
StorageBLOB_READ_WRITE_TOKEN (run vercel env pull after linking)
EmailSMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
SlackSLACK_WEBHOOK_URL, SLACK_CHANNEL, SLACK_USERNAME, SLACK_ICON_URL, GIBPOTATO_API_KEY
SentrySENTRY_DSN (server), VITE_SENTRY_DSN (client), SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT

Testing

  • Vitest + happy-dom, real Postgres (no mocking of the DB)
  • test/setup-test-env.ts truncates all tables before each test and reseeds DefaultFixtures.DEFAULT_USER
  • Test fixtures in app/lib/test/fixtures.ts use faker + a cuid suffix to guarantee uniqueness (don't remove the suffix — it prevents flaky collisions on Category.slug, Category.name, Feed.name)
  • Route tests live as siblings: app/routes/p.$postId._index.test.ts next to app/routes/p.$postId._index.tsx
  • Every auth-protected endpoint must have a test that hits it without a session and asserts the redirect

Most route test files carry // @ts-nocheck because RR7's LoaderFunctionArgs type requires unstable_url and unstable_pattern fields that test argument builders don't currently populate. This is an accepted trade-off; a proper test helper that builds compliant LoaderFunctionArgs would be a future cleanup.

Deployment

Deploys run on Vercel. The repo is wired to the Vercel project via the Vercel GitHub integration:

  1. Production deploys: merges to main → Vercel production.
  2. Preview deploys: every push to a feature branch → Vercel preview with its own Neon branch.
  3. Build command: pnpm build (runs react-router build).
  4. Output: Vercel React Router preset consumes build/client + build/server.
  5. Sourcemaps auto-upload to Sentry when SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT are set (via @sentry/vite-plugin).

Vercel dashboard: https://vercel.com/sentry/vanguard

Things NOT to do

  • ❌ Don't use npm or yarn — pnpm is enforced by the preinstall hook.
  • ❌ Don't import from @prisma/client — it's gone. Types come from Drizzle schema (typeof table.$inferSelect).
  • ❌ Don't import from @remix-run/* — use react-router.
  • ❌ Don't use json() for responses — return plain objects, or Response.json(body, { status }) / data(body, { status }) for non-200.
  • ❌ Don't use unstable_parseMultipartFormData — use request.formData().
  • ❌ Don't put await requireUserId(request, context) (two-arg form) — the context is gone.
  • ❌ Don't commit .react-router/ — it's generated and gitignored.
  • ❌ Don't add @ts-ignore or @ts-expect-error to production code without a clear comment explaining the library-level issue being suppressed. Test files may use @ts-nocheck for RR7 stub-arg friction.
  • ❌ Don't introduce background work without waitUntil — un-awaited promises die when the response sends.

References

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.