My widdle cworner of ze intwernet
Live at vedant.to. To run your own copy, start at Deploy your own.
The content rules here are enforced by something that can fail rather than by remembering
them. draft: true is applied once, in lib/posts.ts and lib/daily.ts; the pages, both
feeds and the sitemap all read that one filtered list, so the four outputs cannot disagree
about what is published. CI runs the content normaliser and then fails on its own diff, so
a .mdoc that would break rendering cannot land quietly. What each gate catches is in
What CI enforces.
Next.js 16 (App Router) with Keystatic as the CMS, Tailwind CSS 4 for styling, TypeScript throughout, deployed on Vercel, with Upstash Redis for rate limiting and Cloudflare R2 for asset and whisper storage. Node 22.
There is no ESLint here. npm run typecheck and Prettier are the static gates, so do not
go looking for npm run lint.
None of it starts here. I write in Notion, edit it there until it is done, and only then publish to the site. That library is also personal data collection on myself, half for whatever self-actualization it eventually adds up to and half so there is a backup if this site ever gets fucked.
Three Keystatic collections, all stored as flat files in the repo:
| Collection | Path | Format |
|---|---|---|
| Posts | content/posts/ |
.mdoc |
| Daily | content/daily/ |
.mdoc |
| Quotes | content/quotes/ |
.yaml |
Posts and daily entries both carry a draft boolean. draft: true hides an entry
everywhere: site listings, RSS, JSON Feed, and the sitemap. It is enforced once, at the
source, in lib/posts.ts and lib/daily.ts; lib/feed-utils.ts and
app/sitemap.xml/route.ts consume the already-filtered getPublishedPosts() /
getPublishedDailyEntries() rather than re-implementing the check. One filter, no drift.
This is also why backups must never land in a public bucket: an unpublished draft is
invisible on the site but sits in plain text in content/. See Backup.
Quotes have no draft field. Everything in content/quotes/ is public.
Images referenced from content live on the CDN, not in the repo. Keystatic writes an
upload to public/images/, scripts/sync-images-to-r2.mjs converts it to WebP and pushes
it to R2 on the next push to main, and the reference in the .mdoc points at
assets.vedant.to. Because the sync converts the file, the reference has to say .webp
too. npm run fix-images rewrites it, and npm run check fails if one was missed.
| path | what it is |
|---|---|
app/(site)/ |
the public pages |
app/api/ |
13 route handlers: the auth endpoints, OG images, upload, whisper, redact |
app/keystatic/ |
the CMS UI |
app/auth/keystatic/ |
the login and device-management screens |
lib/ |
content readers (posts.ts, daily.ts, reader.ts) and the feed/SEO helpers |
lib/auth/, lib/webauthn/ |
the /keystatic gate |
components/ |
the three client components that have to be client components |
content/ |
the three collections above: 9 posts, 17 daily entries, 51 quotes |
public/ |
static assets, including a hand-written robots.txt |
scripts/ |
the content normalisers, the auditor, the R2 image sync, restore.sh |
tests/ |
24 node:test files, 212 cases. tests/manual/ needs a live Redis; CI skips it |
legacy/ |
the archived Framer site that came before this one. Its own Worker, no app code |
docs/ |
the long-form design notes. One so far: docs/auth.md |
.github/workflows/ |
CI and the seven scheduled or triggered jobs (see Deployment) |
proxy.ts |
the /keystatic auth gate. Node.js runtime, and in Next 16 that is not configurable |
.claude/, .conductor/ |
my agent tooling. It does nothing in a fork. Delete it |
A table and not an ASCII tree: the tree that used to be here had drifted wrong in four
places. Regenerate the counts with ls content/posts/*.mdoc | wc -l, the same for
daily and quotes, and npm test for the case count.
git clone https://github.com/itsvedantkumar/vedant.to
cd vedant.to
npm install
cp .env.example .env.local
npm run devThe site is at http://localhost:3000, the CMS at http://localhost:3000/keystatic.
Keystatic runs in local mode when the GitHub credentials are absent, so it writes straight
to disk and needs no OAuth for dev.
Env vars are documented in .env.example, not here. It explains the semantics of the
trickier ones (KEYSTATIC_ENROLL_TOKEN, KEYSTATIC_SESSION_SECRET) better than a table
could, and a second copy would only rot.
Before pushing:
npm run check # tsc --noEmit, then scripts/audit-content.mjs, then npm testFour more scripts help while writing:
| Command | Does |
|---|---|
npm run audit:content:watch |
Runs the content auditor continuously |
npm run fix-content |
Rewrites frontmatter and Markdoc that Keystatic would reject |
npm run fix-images |
Slugifies image filenames and points CDN references at .webp |
npm run format |
Prettier over the repo |
/keystatic is gated by WebAuthn passkeys, with a break-glass password as the recovery
path. Full design, env vars, and known limitations: docs/auth.md.
The one thing worth knowing up front: KEYSTATIC_AUTH_MODE defaults to basic. Anything
other than the literal string passkey keeps the old HTTP Basic Auth prompt, so shipping
the passkey code is deliberately a no-op until you opt in.
If the passkey gate is the only part you want, it lives standalone as a runnable Next.js app at itsvedantkumar/keystatic-passkeys, same code, none of this site around it.
/sidequests has bullets that render as a sealed strip until the reader types a password.
The plaintext is not in the repo, the bundle, or the page: it is AES-256-GCM ciphertext in
the REDACTED_LINES env var, and POST /api/redact decrypts it with a key derived by
scrypt (128 MiB per guess) from the password the reader sends. Nothing public can be brute
forced offline, and every guess, right or wrong, costs one of 5 per IP per hour and 60 site-wide;
without Upstash the route answers 503 rather than run unthrottled. One password covers
every line, so it is typed once; the lines the reader did not click stay strips and open
on a click. Adding or rotating a
line is three commands, in docs/ops.md.
Three hand-rolled route handlers, all export const dynamic = 'force-static', all sharing
lib/feed-utils.ts so the three outputs can't disagree about what's published:
app/rss.xml/, RSS 2.0app/feed.json/, JSON Feed 1.1app/sitemap.xml/, sitemap
They are route handlers rather than Next's sitemap.ts / robots.ts conventions because
the shared-source-of-truth wiring is easier to see this way. robots.txt is a static file
in public/. OG images are generated per-page by next/og at /api/og. scripts/indexnow.mjs
runs as npm's postbuild script and pings IndexNow after every production build, so new
posts get crawled in minutes instead of days.
Production deploys come from Vercel's Git integration on pushes to main, not from
GitHub Actions. That keeps releases independent of Actions billing and quota failures. A
red CI run never blocks a deploy.
Without Actions, two gates still run. npm install's prepare script sets
core.hooksPath to .githooks/, so .githooks/pre-push runs npm run check (typecheck,
content audit, identity guard, tests), prettier --check, eslint, and, when installed
locally, gitleaks over the pushed commits, osv-scanner on package-lock.json, and
zizmor offline. Skip once with git push --no-verify. vercel.json sets buildCommand
to npm run check && npm run build, so Vercel refuses to deploy a commit that fails the
check, the actual deploy gate. To push secrets without Actions: npx --yes vercel@59.1.4 link --yes --project vedant-blog in an empty directory, then printf '%s' "$VALUE" | npx vercel env add NAME production (repeat for preview), then npx --yes vercel@59.1.4 redeploy <latest production url>. The dashboard works too.
The scheduled jobs Actions used to run, backup and health checks, now run on a Cloudflare
Worker, ops/worker/index.mjs, since Actions is a permanent billing lock rather than a
temporary outage. Full detail, schedules and log-reading are in
docs/ops.md. Deploy or redeploy it with:
R2_BUCKET_NAME=<assets bucket> R2_BACKUP_BUCKET_NAME=<backup bucket> npm run ops:deployThat generates ops/worker/wrangler.generated.jsonc (gitignored, so no identity is checked
in) from site.config.mjs and deploys it. One secret has to be set by hand, once:
npx wrangler secret put RESEND_API_KEY --config ops/worker/wrangler.generated.jsoncRun either cron by hand without waiting for the schedule:
npx wrangler dev --config ops/worker/wrangler.generated.jsonc --test-scheduled
curl 'http://localhost:8787/__scheduled?cron=17+3+*+*+*'The site that came before this one was built in Framer. It is preserved verbatim on a
second Worker, legacy/worker/index.mjs, at its own subdomain, with Framer removed from
the serving path entirely: the rewritten HTML is in legacy/site/ and the runtime it needs
(JS chunks, fonts, images, CMS blobs) is mirrored into R2 under fr-mirr/.
npm run legacy:mirror # re-fetch and rewrite
R2_BUCKET_NAME=<assets bucket> npm run legacy:upload # push the runtime to R2
R2_BUCKET_NAME=<assets bucket> npm run legacy:deploy # deploy the Worker
npm run legacy:verify -- --runtime # acceptance test, against the live originThree things make this harder than a wget mirror, all covered in
docs/ops.md. Framer's CMS reader uses its own
?range= query parameter and rejects anything but a 200 of exactly the right length. The
mirrored asset prefix has to be the same byte length as the Framer one it replaces, or every
byte offset inside those binary blobs shifts. And two JS chunks fetched from Framer at
runtime through URLs that no string search of the mirror could find, which is why
legacy:verify --runtime drives a real browser and bans more hosts on the wire than it does
in the markup. None of this touches the Next.js app. If you fork the repo, delete legacy/,
the four legacy:* scripts and legacyUrl in site.config.mjs.
GitHub Actions handles validation and support jobs only:
| Workflow | Name | Trigger | Does |
|---|---|---|---|
ci.yml |
CI | push to main, PR |
build, normalize-content, audit-content, format:check, typecheck, npm test, R2 image sync, Lighthouse CI |
security.yml |
security | PR, push to main, weekly Mon 06:00 UTC |
gitleaks, semgrep, osv-scanner, zizmor, npm audit, but dormant: Actions is billing-locked, and .githooks/pre-push already runs gitleaks, osv-scanner and zizmor locally |
setup-env.yml |
Setup Vercel Env Vars | manual only | syncs secrets to Vercel, forces a redeploy |
Every step below can fail the run, in this order. The cheap static gates come first on purpose, so a formatting typo fails in seconds rather than after a full build.
| Step | Fails when | Catches |
|---|---|---|
normalize-content.mjs && git diff --exit-code |
the normaliser rewrote a tracked file | a .mdoc Keystatic would reject, committed unfixed. The run fails and names the file instead of silently fixing it inside the runner |
audit-content.mjs |
a content rule is broken | CDN references that are not .webp, and the other checks in scripts/audit-content.mjs |
format:check |
Prettier would rewrite a file | formatting drift |
typecheck |
tsc --noEmit reports an error |
type regressions. There is no ESLint here, so this and Prettier are the whole static gate |
npm test |
any of the 212 cases fails | the draft: true invariant (tests/draft-invariant.test.ts), the auth gate (guard, session, enrollment, counter, timing), the whisper route and its quiz, and metadata generation |
next build |
the build errors | a route that only breaks at build time. Runs with placeholder Keystatic secrets; Vercel rebuilds with the real ones |
| Lighthouse CI | performance, accessibility or SEO scores below 0.90 on /, /blog, /daily, /quotes, over 3 runs |
a regression in any of the three. Best-practices is a warning, not an error. Thresholds live in lighthouserc.json |
A red run does not block the deploy. Vercel deploys from main independently, so CI is
a report rather than a gate. That is the trade for keeping releases off Actions quota.
This is a personal site rather than a template. The button gets you a running copy of my site; the steps below turn it into yours. Nothing here is hidden: one config file carries my identity, and Make it yours covers the rest.
Three accounts, and that is the whole list: GitHub (the fork, and Keystatic's storage in production), Vercel (hosting), and an agent you are already signed in to, such as Claude Code or Cursor. Every command-line tool this repo touches is installed by step 4, so you do not have to set up a toolchain by hand.
You do not need vercel login. Nothing here drives the Vercel CLI from your local
session. The one place it runs is .github/workflows/setup-env.yml:98, as a pinned
npx vercel@59.11.0 deploy --prod --token "$VERCEL_TOKEN". What Vercel needs from you is
three GitHub Actions secrets, not a login:
gh secret set VERCEL_TOKEN # vercel.com/account/tokens
gh secret set VERCEL_ORG_ID # Vercel → Settings → General
gh secret set VERCEL_PROJECT_ID # Vercel → your project → Settings → GeneralPrefer clicking? Set the same values in the Vercel dashboard and skip setup-env.yml. It
exists so secrets live in one place and reach Vercel in one run.
- Fork the repo, then clone your fork.
- Run
nvm use,npm install,cp .env.example .env.local, thennpm run dev. The site comes up athttp://localhost:3000with the CMS at/keystatic, writing to disk. No credentials are needed yet, and.env.localcan stay empty for now. - Delete my writing:
rm -rf content/posts/* content/daily/* content/quotes/*. Keep the directories and keepkeystatic.config.ts, which defines their shape. - Work through Make it yours before deploying, so the first build already carries your domain.
Edit site.config.mjs. It holds the name, the domain, the asset host,
the GitHub repo Keystatic writes to, the social handles and the three email addresses, and
every file that needs one of those values derives it from there: lib/constants.ts for
TypeScript, next.config.mjs and scripts/*.mjs by direct import, and the workflows through
node scripts/site.mjs <key>.
Then run npm run check. Its check-identity step greps the tracked tree for my values and
fails on any it finds outside site.config.mjs, prose and content excepted, so a copy you
missed cannot fail open the way the old audit-content regex did.
What is still yours to change by hand, because it is not a string:
| Where | What |
|---|---|
app/icon.png, app/apple-icon.png, public/icon-192.png |
favicon and app icons. Still mine until you replace |
public/<key>.txt |
IndexNow key. Regenerate, do not reuse mine |
package.json, LICENSE, SECURITY.md, this README |
project name, author, copyright, reporting address |
content/ |
my writing, see step 2 |
.env.example to .env.local |
fresh secrets, see the agent prompt below |
Rip out what is not yours. /whisper is an anonymous-message endpoint gated by a
personal-trivia quiz. The questions live in the WHISPER_QUIZ env var, deliberately not in
the repo, so it fails closed with a 503 until you write your own. /sidequests is my
timeline, and its password-gated lines read REDACTED_LINES, which fails the same way when
unset. components/ holds two
easter eggs: easter-egg.tsx prints a line to the devtools console site-wide, and
post-console-art.tsx prints ASCII art on every post and daily entry, hand-drawn for four
slugs and generated from the slug for the rest. CLAUDE.md, .claude/ and .conductor/
are my agent configuration and mean nothing in your fork.
Before pasting this, finish step 1: the agent needs to be signed in and to have push access to your fork. It cannot create your DNS records, Vercel project, R2 buckets, Upstash database, or GitHub OAuth app, and the prompt ends by telling it to say so rather than pretend.
This repo is a fork of itsvedantkumar/vedant.to, a Next.js 16 + Keystatic blog. Rebrand it
for me and leave the architecture alone.
My details:
- Name: <YOUR NAME> - Short site name: <SHORT NAME>
- Domain: <example.com> - Asset host: <assets.example.com>
- GitHub: <owner>/<repo> - X: <@handle> - LinkedIn: <url>
- Contact email: <you@example.com>
Do not run `vercel login`; this repo never uses a local Vercel session.
Do this:
1. Set up the toolchain before anything else, installing whatever is missing rather than
asking me to. This repo pins Node 22 in .nvmrc and package.json "engines", so run
`nvm install 22 && nvm use` (install nvm first if absent). Then `npm install`. Install
the GitHub CLI if it is missing and run `gh auth login` if `gh auth status` fails; it is
needed to set Actions secrets. Install the `aws` CLI only if I say I want the R2 backup
and restore scripts. The ops Worker deploy runs through `npx wrangler`, so nothing extra
to install there. Report the versions you ended up with.
2. Put my details into site.config.mjs. That is the only code file that carries the
previous owner's identity; everything else derives from it. Then update package.json
("name", "author"), LICENSE, SECURITY.md and docs/auth.md, which are prose and are not
derived. Run `node scripts/check-identity.mjs` and fix anything it lists.
3. Empty content/posts/, content/daily/, and content/quotes/. Do not change
keystatic.config.ts's collection schemas.
4. Delete the /whisper feature: app/(site)/whisper/, app/api/whisper/, lib/whisper-quiz.ts,
its lines in public/robots.txt, and its env vars in .env.example (WHISPER_TOKEN_SECRET,
WHISPER_QUIZ, WHISPER_BUCKET_NAME, WHISPER_TO_EMAIL) and in .github/workflows/setup-env.yml.
Delete /sidequests and its password gate: app/(site)/sidequests/, app/api/redact/,
components/redacted.tsx, lib/redact.ts, lib/redacted-store.ts, scripts/redact.mjs,
tests/redact*.test.ts, the
`.redacted-*` block in app/globals.css, the link on app/(site)/page.tsx, the
`/sidequests` entry in scripts/indexnow.mjs, and REDACTED_LINES in .env.example.
5. Delete the easter eggs: components/easter-egg.tsx and components/post-console-art.tsx,
plus their imports in app/layout.tsx, app/(site)/blog/[slug]/page.tsx AND
app/(site)/daily/[slug]/page.tsx. Both page files import PostConsoleArt.
6. Delete CLAUDE.md, .claude/ and .conductor/. They are the previous owner's agent tooling.
7. Replace the IndexNow key: generate a new 32-character hex string, and rename
public/<old-key>.txt to <new-key>.txt with the key as its only content.
scripts/indexnow.mjs discovers the key file by name, so nothing else to change.
8. Generate fresh values for every secret in .env.example and write them to .env.local,
which you should create by copying .env.example. Each of KEYSTATIC_SECRET,
KEYSTATIC_SESSION_SECRET, KEYSTATIC_ENROLL_TOKEN and UPLOAD_SECRET must be a new random
string of at least 32 characters. Never reuse a value from the upstream repo.
9. Rewrite the intro paragraphs of README.md and the homepage copy in app/(site)/page.tsx
in my voice. Update the Vercel deploy button URL at the top of the Deploy your own
section to point at my repo.
10. Run `npm run check` and `npm run build` and fix what breaks. `check` includes
check-identity, so a leftover value from the previous owner fails here rather than
shipping.
11. Then list for me, and do not attempt yourself: the DNS records, the Vercel project and
its three Actions secrets (VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID), the
Cloudflare R2 buckets, the Upstash Redis database, the GitHub OAuth app, replacing
app/icon.png and app/apple-icon.png, and enrolling a passkey at /auth/keystatic/enroll.
The site builds and serves content with none of these. Add each one when you want what it does.
| Service | Needed for | Without it |
|---|---|---|
| GitHub OAuth app | /keystatic in production |
The build fails; CI uses placeholders |
| Vercel | Hosting | No hosting |
| Upstash Redis | Rate limiting, passkey storage | Limits are skipped; /keystatic still fails closed |
| Cloudflare R2 (public) | Image hosting | Uploads return 503 |
| Cloudflare R2 (private) | Daily backups, whisper messages | the ops Worker's backup run and /api/whisper fail |
| Resend | Security alerts and whisper email | Alerts are dropped |
| proxycheck.io | VPN detection on /whisper |
The call still runs, unkeyed on the free tier |
| PostHog | Analytics, replays, errors | Nothing is sent; the client never initialises |
Three env vars get a production build to pass: KEYSTATIC_GITHUB_CLIENT_ID,
KEYSTATIC_GITHUB_CLIENT_SECRET, and KEYSTATIC_SECRET. Add KEYSTATIC_AUTH_PASSWORD and
you can also get into /keystatic; without either that password or an Upstash Redis, the
gate fails closed and answers 503. .env.example documents all 27 and says which are
required. Set them in the Vercel dashboard, or put them in GitHub Actions secrets and run
setup-env.yml, which pushes them to Vercel and redeploys. Without Actions, add them
directly with npx vercel env add; see
When GitHub Actions is unavailable.
Once /keystatic is reachable, enroll a passkey at /auth/keystatic/enroll and set
KEYSTATIC_AUTH_MODE=passkey. Enroll a second one before you rely on it: the break-glass
password needs Upstash reachable, so a single passkey plus a Redis outage locks you out.
docs/auth.md has the runbook.
One thing I would change if starting over. The feeds and sitemap are hand-rolled route
handlers rather than Next's sitemap.ts / robots.ts conventions. That was a deliberate
call for a visible shared source of truth, but the conventions are less code.
Worth knowing before you fork it.
- CI cannot stop a bad deploy. Vercel ships on push to
mainwhatever CI says. If that trade is wrong for you, add a branch protection rule; nothing in the repo does it for you. - The image pipeline hardcodes
assets.vedant.toin three places and fails open on any other host:scripts/audit-content.mjs:125,140andscripts/normalize-images.mjs:54,72. Point your content at a different CDN and the checker stops recognising your references, sonpm run checkpasses, whilenpm run fix-imagesquietly rewrites nothing. Meanwhilescripts/sync-images-to-r2.mjsstill uploads the file as.webp, so the reference and the object disagree and the image 404s in production. Change the host in all three files together. - The passkey gate is off by default.
KEYSTATIC_AUTH_MODEhas to be the literal stringpasskey. Anything else keeps HTTP Basic Auth, so shipping the WebAuthn code changes nothing until you opt in. - Upstash and R2 are optional, but not equally. The site builds and serves without either. Without Upstash there is no rate limiting; without R2 the whisper route and image uploads have nowhere to write.
- macOS and Linux only. Nothing here has been run on Windows, and
scripts/restore.shis bash. - No ESLint. Deliberate, and it means a lint rule you rely on elsewhere is not running here.
A Cloudflare Worker, ops/worker/index.mjs, runs a backup every day at 03:17 UTC. Full
schedule, limits and how to run it or read its logs by hand are in
docs/ops.md. Each run:
- Fetches the GitHub tarball of
mainand writes it to the private backup bucket asbackups/repo-YYYY-MM-DD.tar.gz, pruning copies older than 30 days. - Mirrors the live asset bucket into
s3://$R2_BACKUP_BUCKET_NAME/r2-assets/, capturing objects that exist only in R2 (API uploads, whisper messages) and have no copy in the repo. The free Workers plan caps subrequests at 50 a run, so at most 18 objects mirror per day; a large batch converges over the following days instead of failing.
Backups go to a private bucket (R2_BACKUP_BUCKET_NAME), never to R2_BUCKET_NAME. The
latter is the live asset bucket, fronted by the public domain assets.vedant.to. Anything
written there is world-readable, and backups can contain unpublished drafts. Keep these
separate.
Hand the tarball straight to scripts/restore.sh. It verifies the archive, refuses absolute
or parent-relative entries, lifts content/ and public/images/ out of the nested
<owner>-<repo>-<sha>/ directory GitHub tarballs use, and moves any existing copies aside
to *.bak-<timestamp> before restoring:
scripts/restore.sh repo-YYYY-MM-DD.tar.gzA zip holding content/ and public/images/ at its root (the old Actions format) still
works the same way.
R2-only objects restore in the opposite direction, with
aws s3 sync s3://$R2_BACKUP_BUCKET_NAME/r2-assets/ s3://$R2_BUCKET_NAME/.
- docs/auth.md — the
/keystaticgate: WebAuthn design, the break-glass password path, every env var it reads, and how to verify the gate is live in production. - docs/ops.md, the ops Worker: what runs where, schedules, limits, how to deploy, run a job by hand, and read the logs.
- .env.example — every environment variable, with the semantics of the awkward ones written next to them. Deliberately the only copy; a table in this file would rot against it.
Next.js, Keystatic, Tailwind CSS, and Vercel do the heavy lifting. The passkey gate is SimpleWebAuthn. Syntax highlighting is sugar-high.
MIT. The code is yours to take; the writing in content/ is not.