Hey there, Soldier! Welcome to the Vets Who Code Web App. This project serves as a communal code base where military vets and their spouses can sharpen their coding skills. π
This app is built using a modern tech stack including:
- Next.js 15 (Pages Router)
- TypeScript
- Tailwind CSS
- Prisma + Postgres
- NextAuth (GitHub OAuth)
- MDX for content
- Vitest and Playwright for testing
-
Empower Veterans and Military Spouses: We're creating a production-grade app that addresses the unique needs of our community.
-
Ever-Evolving Platform: New features are continuously added to provide valuable tools for our users.
-
Learn By Doing: The project serves as a hands-on experience for our community to learn and grow their coding skills.
To get a local copy up and running, you'll need a few things installed on your machine.
- Git
- Node.js v20 (see
.nvmrc) - NVM
- npm β the only supported package manager;
preinstallrejects yarn, pnpm, and bun
Fire up your terminal and run:
$ git clone https://github.com/Vets-Who-Code/vets-who-code-app.git
$ cd vets-who-code-app
$ nvm use
$ npm install
$ npm run devNavigate to http://localhost:3000/ to see the app in action.
Copy the template, then fill in the values you need:
$ cp .env.example .env.localSet DATABASE_URL to a Postgres server you can reach before you bootstrap the database β the schema's provider is postgresql, so a SQLite file: URL is rejected outright. Run one locally, or point at a free Neon branch:
# one way to get a local Postgres β skip if you are using Neon
$ docker run --rm -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=vwc_dev -p 5432:5432 postgres:16-alpine
# DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vwc_dev"
# then, with DATABASE_URL set in .env.local:
$ npm run dev:setup # first-time database bootstrap (prisma generate && prisma db push).env.local and .env are gitignored β never commit them. .env.example is the template and the list of every variable the app reads; each entry there is annotated with whether it is required, whether it is a secret, and which file consumes it.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Prisma connection string (prisma/schema.prisma). Must be postgresql:// β the datasource provider is postgresql, so a file: SQLite URL fails validation with P1012. |
NEXTAUTH_SECRET |
Session encryption key. Generate one with openssl rand -base64 32. |
NEXTAUTH_URL |
Base URL of the app. http://localhost:3000 in development. |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
GitHub OAuth app credentials. Create an app at github.com/settings/developers with the callback URL http://localhost:3000/api/auth/callback/github. |
GITHUB_ORG |
GitHub organization that gates sign-in. Membership is the sole login gate β there is no allowlist and no dev bypass. |
/api/health reports the environment as unhealthy when DATABASE_URL, NEXTAUTH_SECRET, or NEXTAUTH_URL is missing.
Every variable below is optional. The feature that reads it stays off, or falls back to a default, when it is unset.
| Feature | Variables |
|---|---|
| AI assistant and content scripts | PRIMARY_AI_PROVIDER, GOOGLE_GENERATIVE_AI_API_KEY, GEMINI_API_KEY, GOOGLE_PRIVATE_KEY, GEMINI_MODEL, TECH_PATHWAYS_MODEL, AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT, OPENAI_API_KEY, PHI3_ENDPOINT, PHI3_API_KEY |
| J0dI3 AI backend | J0DI3_API_URL, J0DI3_API_KEY |
| Slack form notifications | APPLY_WEBHOOK_ID, CONTACT_WEBHOOK_ID, MENTOR_WEBHOOK_ID |
| GitHub API reads (org, repos, PRs) | GITHUB_TOKEN |
| Cloudinary media | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET |
| Email (Resend) | RESEND_API_KEY, EMAIL_FROM |
| Shopify commerce | SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_ACCESS_TOKEN, SHOPIFY_WEBHOOK_SECRET (or SHOPIFY_API_SECRET / SHOPIFY_CLIENT_SECRET) |
| Labor-market data | LIGHTCAST_CLIENT_ID, LIGHTCAST_CLIENT_SECRET, CENSUS_API_KEY |
| Public site config | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID, NEXT_PUBLIC_COHORT_START_DATE, NEXT_PUBLIC_SITE_URL |
| Database seeding | ALLOW_DESTRUCTIVE_SEED β "true" lets npx prisma db seed wipe a non-local database (prisma/seed-guard.ts) |
NEXTAUTH_URLishttp://localhost:3000locally and the deployed origin in production.DATABASE_URLis Postgres in both environments β your own local or Neon branch in development, the project's Neon database in production.- Production values live in the Vercel project settings, not in any file in this repo.
NEXT_PUBLIC_*values are inlined into the browser bundle at build time. Never put a secret behind that prefix.
We support development containers for an easier setup experience.
-
Clone the repository
git clone https://github.com/Vets-Who-Code/vets-who-code-app.git cd vets-who-code-app -
Open in VS Code
- Open the root directory in VS Code.
- When prompted, choose "Reopen in Container"
- Or use Command Palette (
F1) and runRemote-Containers: Reopen in Container.
-
Start Developing
- Once the container is built and running, you're ready to code!
Remember, this is optional. If you prefer to set up your development environment manually, you can continue to do so.
Blog headers, audio overviews, and inline graphics are generated from the post's markdown and uploaded to Cloudinary. The media files never live in git; the post references them by Cloudinary path.
Every script reads NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, and GEMINI_API_KEY from .env.
npm run generate:blog-media <slug> # header image + audio overview in one run
npm run generate:blog-image <slug> # header image onlyThe header lands at blog-images/<slug>; reference it in the post's front matter as image.src: "blog-images/<slug>.png". The audio lands at blog-audio/<slug>.wav, and the post page picks it up by slug.
How the header image is made
- Scrapes/reads the blog markdown file in system blog folder.
- Blog title, content and summary are returned to inject in a dynamic prompt that is given to Google Gemini.
- Gemini returns JSON that becomes the prompt for a Gemini image model, which builds the image.
- After the image is generated it is uploaded to Cloudinary into the blog-images folder.
Inline graphics are HTML artboards styled by src/data/blog-graphics/_brand.css, rendered to PNG with Playwright, then uploaded.
mkdir -p src/data/blog-graphics/<slug>
npm run generate:blog-graphic <slug> -- --draft <name> "<what the graphic should say>" # Gemini drafts <name>.html
npm run generate:blog-graphic <slug> -- --dry # render PNGs to out/ for review, no upload
npm run generate:blog-graphic <slug> # render and upload every graphic in the folderEdit the drafted HTML until the --dry render reads right, then upload. Embed each one in the post as . The per-post folders are gitignored: the sources stay on your machine and only the PNGs are published.
public/audio/blogs/ is a local staging directory β it is gitignored and never committed. npm run generate:blog-audio writes WAVs there on the way to Cloudinary; the site itself always plays from Cloudinary (see src/lib/blog.ts), and .vercelignore keeps the directory out of deploys. The newer per-post script (npm run generate:blog-media) streams straight to Cloudinary and does not use this directory at all.
Biome is the only linter and formatter β there is no ESLint or Prettier config.
npm run typecheck # tsc, no emit
npm run lint # Biome lint, no writes
npm run lint:fix # typecheck, then biome check --write
npm run format # Biome format only
npm run check # biome ci β what to run before pushingA pre-commit hook runs biome check --write on staged files, and a commit-msg hook enforces Conventional Commits.
Unit and component tests run on Vitest. End-to-end tests run on Playwright.
npm test # run every unit and component test once
npx vitest # watch mode
npx vitest run src/utils/__tests__/string.test.ts # a single file
npx vitest run -t "falls back to the post title" # a single test, matched by name
npx vitest run --coverage # with a coverage reportnpm test is an alias for vitest run.
__tests__/ # mirrors src/: api, components, data, lib, pages, prisma, scripts, utils
src/**/__tests__/ # co-located: src/hooks, src/lib/interactive-lessons,
# src/lib/lesson-sandbox, src/utils
tests/ # Playwright only β excluded from Vitest
a11y/*.spec.ts # axe-core WCAG A/AA scan of public routes (chromium only)
e2e/*.spec.ts
security/*.spec.ts
Vitest picks up __tests__/**/*.{test,tests}.{ts,tsx} and src/**/__tests__/**/*.{test,tests}.{ts,tsx}. The top-level tests/ directory is excluded from Vitest and belongs to Playwright, whose specs are named *.spec.ts.
House style, set by vitest.config.mts and vitest.setup.ts:
globals: trueis on, so do not importdescribe,it, orexpect.- DOM matchers come from
vitest-dom, sotoHaveAttributeandtoBeInTheDocumentwork with no extra import. - Import through the path aliases (
@components/...,@lib/...), never deep relative paths across feature boundaries. - Mock with
vi.fn()andvi.mock(). - For interactions, use
fireEventfrom@testing-library/react.
A component test (__tests__/components/blog-card.test.tsx):
import BlogCard from "@components/blog-card/blog-03";
import { render, screen } from "@testing-library/react";
const PROPS = {
path: "/blog/combat-to-code",
title: "From Combat to Code",
category: { title: "Career", slug: "career", path: "/blog/category/career" },
postedAt: "Jan 1, 2026",
};
describe("BlogCard (blog-03)", () => {
it("falls back to the post title for the image alt when none is provided", () => {
render(<BlogCard {...PROPS} image={{ src: "/images/combat-to-code.png" }} />);
expect(screen.getByRole("img")).toHaveAttribute("alt", "From Combat to Code");
});
});A utility test (src/utils/__tests__/string.test.ts):
import { capitalize } from "../string";
describe("capitalize", () => {
it("capitalizes each word", () => {
expect(capitalize("hello world")).toBe("Hello World");
});
});No thresholds are enforced, but we aim for:
| Metric | Target |
|---|---|
| Statements | 70%+ |
| Branches | 60%+ |
| Functions | 70%+ |
| Lines | 70%+ |
Generate and open the HTML report:
npx vitest run --coverage
open coverage/index.htmlnpx playwright install # first time only
npx playwright test # every spec
npx playwright test --project=chromium # one browser
npx playwright test tests/e2e/interactive-lesson.spec.ts # one fileProjects defined in playwright.config.ts: chromium, firefox, Mobile Chrome, Microsoft Edge, Google Chrome.
The Microsoft Edge and Google Chrome projects drive system-installed branded browsers, which plain npx playwright install does not download β a bare npx playwright test fails those two projects with Chromium distribution 'msedge' is not found. Either run npx playwright install msedge chrome first, or stay on the bundled browsers:
npx playwright test --project=chromium --project=firefox --project="Mobile Chrome"Locally, Playwright runs npm run build && npm run start before the specs, so the first run takes a couple of minutes. Specs that need Shopify or NextAuth credentials skip themselves when those environment variables are missing.
- Add tests with new behavior, and a regression test with every bug fix.
- Test observable behavior β rendered output, state changes, API responses β not internals.
- Keep one assertion focus per test.
- Give tests descriptive names that read as sentences.
- Mock external services with
vi.mock(); never call a real API from a unit test. - For API routes, cover success, validation failure, and external-service failure.
Both suites run on every pull request to master. .github/workflows/vitest.yml runs npx vitest run --reporter=verbose, and .github/workflows/playwright.yml runs a chromium + firefox matrix. The tests/a11y axe scan runs on the chromium leg only and skips itself on every other project, so an accessibility violation on a public route fails the test (chromium) check with the route, rule id and selector in the log.
- Vitest documentation
- Testing Library (React)
- Playwright documentation
- Still stuck? See Further Help in the contributing guide.
AGENTS.mdβ architecture, where new code goes, path aliases, auth guards, and conventions. Written for AI coding agents, and the fastest orientation for humans too.docs/β deep dives: design system, brand style guide, database, deployment, Shopify, email, and more./api-docsβ Swagger UI for every API route, generated at build time from@swaggerJSDoc blocks. The raw spec is served at/api/docs.
We love contributions! Please read our Contributing Guidelines to get started.
Curious about upcoming features? Check our Roadmap.
This project is under the GNU Affero General Public License v3.0 β see the LICENSE for details.
Made with β₯ by veterans, for veterans