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
In this repositoryOne other file it gives its agents.
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
- Very specific: line 124
Every data access helper ends in .server.ts and lives under app/models/.
- Very specific: line 180
Client-visible env: must be prefixed VITE_ and read via import.meta.env.VITE_FOO — no more window.CONFIG injection.
- Very specific: line 185
Use request.formData() (Web Standard, built into RR7) — never unstable_parseMultipartFormData.
- Very specific: line 239
Test fixtures in app/lib/test/fixtures.ts use faker + a cuid suffix to guarantee uniqueness (don't remove the suffix — it prevents flaky …
- Distinctive: line 241
Every auth-protected endpoint must have a test that hits it without a session and asserts the redirect
≈ “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.
Files
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) orpnpm - ORM: Drizzle —
drizzle-orm/neon-serverless(WebSocket-pooled@neondatabase/serverless) on Vercel, falling back todrizzle-orm/node-postgres(TCPpg) 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 customGoogleStrategysupporting thehdworkspace-domain parameter - Observability:
@sentry/react-routerfor 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 viavp 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:5173Daily
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 browserCLI 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 $paramfor dynamic segments:p.$postId._index.tsx→/p/:postId- Trailing
_opts OUT of parent layout:p.$postId_.edit.tsxis NOT nested underp.$postId - Leading
_= layout-only (no segment):_index.tsx→/,admin.tsxis 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) => { ... })— usetxinside, NOTdb - 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 | undefinedgetUser(request)→User | undefinedrequireUserId(request)→string(throws redirect to/login?redirectTo=...)requireUser(request)→UserrequireAdmin(request)→User(throws redirect to/403if 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 });orreturn data({ error }, { status: 400 })fromreact-router(usedata()when you need headers alongside). - Redirects:
throw redirect("/login")orreturn redirect("/foo")— fromreact-router.
Config injection (client + server)
- Server-only env:
process.env.FOO— anywhere in*.server.tsor loaders/actions. - Client-visible env: must be prefixed
VITE_and read viaimport.meta.env.VITE_FOO— no morewindow.CONFIGinjection. app/config.tsxcentralises 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:
| Category | Vars |
| Core | DATABASE_URL, SESSION_SECRET, BASE_URL |
| Auth | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_HD |
| Storage | BLOB_READ_WRITE_TOKEN (run vercel env pull after linking) |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM | |
| Slack | SLACK_WEBHOOK_URL, SLACK_CHANNEL, SLACK_USERNAME, SLACK_ICON_URL, GIBPOTATO_API_KEY |
| Sentry | SENTRY_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.tstruncates all tables before each test and reseedsDefaultFixtures.DEFAULT_USER- Test fixtures in
app/lib/test/fixtures.tsuse faker + a cuid suffix to guarantee uniqueness (don't remove the suffix — it prevents flaky collisions onCategory.slug,Category.name,Feed.name) - Route tests live as siblings:
app/routes/p.$postId._index.test.tsnext toapp/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:
- Production deploys: merges to
main→ Vercel production. - Preview deploys: every push to a feature branch → Vercel preview with its own Neon branch.
- Build command:
pnpm build(runsreact-router build). - Output: Vercel React Router preset consumes
build/client+build/server. - Sourcemaps auto-upload to Sentry when
SENTRY_AUTH_TOKEN+SENTRY_ORG+SENTRY_PROJECTare 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
preinstallhook. - ❌ Don't import from
@prisma/client— it's gone. Types come from Drizzle schema (typeof table.$inferSelect). - ❌ Don't import from
@remix-run/*— usereact-router. - ❌ Don't use
json()for responses — return plain objects, orResponse.json(body, { status })/data(body, { status })for non-200. - ❌ Don't use
unstable_parseMultipartFormData— userequest.formData(). - ❌ Don't put
await requireUserId(request, context)(two-arg form) — thecontextis gone. - ❌ Don't commit
.react-router/— it's generated and gitignored. - ❌ Don't add
@ts-ignoreor@ts-expect-errorto production code without a clear comment explaining the library-level issue being suppressed. Test files may use@ts-nocheckfor RR7 stub-arg friction. - ❌ Don't introduce background work without
waitUntil— un-awaited promises die when the response sends.
References
- RR7 docs: https://reactrouter.com/start/framework/installation
- Drizzle docs: https://orm.drizzle.team/
- Vercel Blob: https://vercel.com/docs/storage/vercel-blob
- Neon docs: https://neon.tech/docs/
- Sentry + RR7: https://docs.sentry.io/platforms/javascript/guides/react-router/
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
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.

