Deep dive: web platform
The platform a veteran nonprofit runs on
vetswhocode.io takes every application, mentor sign-up and hiring inquiry Vets Who Code receives. It is also the production codebase veterans in the program learn to ship to.
- 310 of 361commits to master in the last 12 months are mine
- 215routes in the production build
- ~635Vitest cases in 81 files, run on every PR
- 250 MB → 31 MBserver bundle, after a deploy-blocking size failure
- Role
- Founder and lead maintainer. First commit 4 February 2017; 627 of 1,485 commits on master.
- Stack
- Next.js 15 (Pages Router), TypeScript, Tailwind CSS, Markdown and MDX content, Vitest, Playwright, Biome, Vercel
- Integrations
- Slack webhooks, Google Gemini, Shopify Storefront, Cloudinary, GitHub API, Lightcast, Census ACS
- License
- AGPL-3.0, open source
- Counted
- 10 November 2026, from the repository history and build output
The problem
Vets Who Code is a nonprofit that trains veterans and military spouses to become software engineers at no cost to them. The website is the front door for all of it. A veteran applies there, a mentor volunteers there, an employer asks to hire there, and a donor decides there.
The same repository is a teaching tool. People in the program make their first production pull requests against it, so the codebase has to be safe for someone new to change. The organization runs on donations, so the platform also has to be cheap to host and need very little operating attention.
What it has to do
- Never lose a real message. If any dependency fails, an application or inquiry still reaches a person.
- Run locally in five minutes with no secrets. Every environment variable is optional, and the feature that reads it turns off or falls back when it is unset.
- Gate every change. Type checks, unit tests, browser tests and an accessibility scan run on each pull request.
- Need no database. Content lives in the repository, so there is no database to host, back up or migrate.
Approach and architecture
The site is a Next.js application deployed on Vercel. Content is file-based: 45 blog posts in Markdown, media entries in MDX, and team, event, project and curriculum data in JSON and TypeScript. Server code is limited to a small set of API routes that validate form input and hand it to Slack, plus a Shopify storefront proxy for the store.
Data model
There is no database, so the data model is the set of typed files under src/data.
| Data | Stored as | Read by |
|---|---|---|
| Blog posts | Markdown with front matter, one file per post | src/lib/blog.ts |
| Press and media | MDX, one file per appearance | Media pages |
| Outcome statistics | One TypeScript module, pinned by a test | Every page that shows a number |
| Curriculum graph | Generated JSON: 375 topics, 529 prerequisite edges | Curriculum and standard pages |
| Career guides | Generated JSON keyed by military job code | MOS Translator |
| Form submissions | Not stored. Forwarded to Slack. | The team, in Slack |
Key tradeoffs
The spam filter fails open
The contact form uses Gemini to classify each message as relevant, spam or unclear. A message is dropped only when the model says spam with at least 0.8 confidence. A missing API key, an API error, an empty response or malformed JSON all result in the message being delivered. The cost is that some spam reaches Slack. The alternative was a filter that could silently discard a veteran’s message. Recorded as DR-001.
Files instead of a database
Content changes go through pull requests, which gives review, history and rollback for free and removes a hosted dependency. The cost is that non-engineers cannot publish without help, and anything that needs per-user state has to live somewhere else. The learning management system, which does need it, moved to a separate application in September 2026. Recorded as DR-002.
An in-memory rate limiter
Each public endpoint has its own sliding-window limit per client IP, for example 5 requests a minute on the contact form and 3 every 10 minutes on applications. Counters live in process memory. That needs no extra service, and it is only exact on a single instance. The call sites are written so the store can be swapped for a shared one without changing them. Recorded as DR-003.
One source for every outcome number
Placement rate, alumni earnings and veterans trained are defined once, with their source and date, and a test pins the values. A statistic cannot drift between the home page and the donate page. Recorded as DR-004.
Measurable outcome
- The production deploy was failing Vercel’s 250 MB serverless limit. After moving a 57 MB editor dependency to a dynamic import, taking Playwright out of runtime dependencies and narrowing file tracing, server pages total 31 MB and API routes 932 KB.
- Every pull request runs about 635 Vitest cases and 7 Playwright specs in Chromium and Firefox, including an axe accessibility scan.
- CodeQL and a dependency audit run on every push and weekly.
- The organization this platform serves has trained more than 300 veterans since 2014, with a 97% job placement rate and more than $20M in collective alumni earnings (internal cohort data, reported by Diginomica, December 2025).
Engineering signals
| Area | What is in the repo |
|---|---|
| Language settings | TypeScript strict plus unused-locals, unused-parameters, implicit-returns and implicit-override checks. Biome with about 119 rules set to error, including a large accessibility block. |
| Dependencies | npm only, enforced at install. Dependabot weekly with grouped minor and patch updates. |
| Tests | 81 Vitest files, 7 Playwright specs covering end-to-end flows, accessibility and route protection. |
| CI | Four workflows in .github/workflows: unit tests, Playwright, security, and a non-blocking code quality score on pull requests. |
| Deploy | Vercel builds and deploys master to production. Production secrets live in Vercel project settings, never in the repo. |
| Observability | Vercel Analytics and Speed Insights for Core Web Vitals, and /api/health returning status, version and uptime. Application logging is console output read in Vercel. |
| Security | No user accounts in this app, so no auth surface. HSTS with preload, a Content Security Policy, DOMPurify for rendered HTML, length and field validation on every form, a honeypot field, and a published SECURITY.md with response targets. |
| Model input and output | The classifier runs at temperature 0 with a JSON response schema. The model’s answer can only ever decide whether one message is forwarded to Slack. |
AI collaboration note
I use AI coding tools on this codebase. Every change, whoever drafted it, goes through the same pull request gates: type check, Biome, Vitest and Playwright. I review it, I merge it, and it ships under my name because I answer for it.
What I’d change in v2
- Build a labelled evaluation set for the spam classifier. Today it has 13 mocked unit tests that prove the fail-open paths. It has no measured precision or recall on real messages.
- Delimit user input in the classifier prompt. Form fields are interpolated directly. The blast radius is small, because the worst outcome is one spam message reaching Slack, and it is still worth closing.
- Move rate-limit counters to a shared store so limits hold across serverless instances.
- Replace console logging with structured logs and publish an uptime target next to the health endpoint.
- Bring first-load JavaScript down. Shared first-load is 148 kB, and 17 of 215 routes are over 200 kB.
Walk me through it
The ten-minute spoken version:
- Who the platform serves and why a lost form submission is the worst failure it can have.
- The request path for a contact message: honeypot, validation, rate limit, classifier, Slack.
- Why the classifier fails open, and what that costs.
- Why content lives in files, and the day that stopped being enough for the LMS.
- The deploy that broke on bundle size, how I found the three causes, and the numbers after.
- What CI checks on every pull request and why new contributors can trust it.
- What I would build next and why it has not been built yet.