- TypeScript 96.8%
- CSS 2.1%
- Dockerfile 1%
| .forgejo/workflows | ||
| app | ||
| components | ||
| data | ||
| features | ||
| lib | ||
| public/assets | ||
| scripts | ||
| styles | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.env.example | ||
| compose.yaml | ||
| Dockerfile | ||
| drizzle.config.ts | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| proxy.ts | ||
| README.md | ||
| tsconfig.json | ||
Wedding Invitation
A cinematic, mobile-first wedding invitation built with Next.js 16 (App Router), Tailwind v4, GSAP + ScrollTrigger, Lenis and SQLite (Drizzle ORM + better-sqlite3). It's a single page, and each scene flows into the next:
- Envelope: a CSS-3D envelope opened by breaking the wax seal. Personal links greet the guest by name.
- Announcement: names, date and message ("Dear …" on personal links).
- Countdown & Love Story: a live countdown in the wedding time zone and a scroll-driven story timeline.
- Details: venues with Google Maps links, the Order of the Day, and the dress code.
- Entourage: parents, sponsors and the wedding party: groups side by side, couples in pairs, headings for sections.
- RSVP: a response card with live validation. It is only usable through a personal link.
- Finale: the couple's names, a closing message, flowers and falling petals.
A black vinyl record fixed at the top-right plays the background music. Guests start it with a tap; it keeps playing as they move through the scenes.
A protected /admin dashboard manages invitations, links and RSVPs, and /admin/web-edit edits every piece of home-page content and media. Architecture rules are in AGENTS.md.
Requirements
- Node 24+
- pnpm 10
better-sqlite3 and sharp ship prebuilt binaries, so you don't need a C++ toolchain.
Setup
pnpm install
cp .env.example .env.local
pnpm admin:hash-password "a long passphrase" # paste both printed lines into .env.local
pnpm db:setup # migrations + sample data; prints the sample invitation links ONCE
pnpm dev # http://localhost:3000
Upgrading an existing database is safe:
- Migrations only add tables and nullable columns.
- The seed never rotates existing invitation links or overwrites real RSVPs.
Environment variables
| Variable | Required | Purpose |
|---|---|---|
ADMIN_PASSWORD_HASH |
for /admin |
scrypt hash of the admin password (pnpm admin:hash-password). The password itself is never stored. |
SESSION_SECRET |
for /admin |
≥ 32 random characters used to sign the admin session cookie. Rotating it signs everyone out. |
DATABASE_PATH |
no | SQLite file location. Default ./data/wedding.db. In production use a persistent, writable volume. |
MEDIA_PATH |
no | Folder for photos and music uploaded in the website editor. Default ./data/uploads. Keep it on the same persistent volume. |
SITE_URL |
no | Origin used when the seed prints sample invitation links. |
Never commit .env.local. .env.example is the only env file in git.
Scripts
| Script | What it does |
|---|---|
pnpm dev / pnpm build / pnpm start |
Develop, build (run db:setup first), serve |
pnpm lint |
ESLint (npx tsc --noEmit for types) |
pnpm test |
Unit and integration tests: invitation access, guest limits, deadlines, authorization, repeated submissions, rate limiting, CSV |
pnpm db:generate |
Generate a migration after editing lib/db/schema.ts |
pnpm db:migrate / pnpm db:seed / pnpm db:setup |
Apply migrations / upsert sample data / both |
pnpm db:deploy |
Production start-up: migrate, and seed only a database without website content |
pnpm db:bundle |
Bundle db:deploy into build/db-deploy.cjs (used by the Docker image) |
pnpm admin:hash-password "<pw>" |
Print ADMIN_PASSWORD_HASH and a fresh SESSION_SECRET |
Personal invitations & RSVP
- Each invitation has a link
/invite/<token>with a 256-bit random token. Only a SHA-256 hash of the token is stored, so a lost link cannot be recovered; generate a new one in/admin, and the old link stops working immediately. - The guest's token is their authorization. It resolves exactly one invitation, so it can never read or change another one.
- Guests can reply and change their reply until
rsvp_deadline(inwedding_config). After that the page becomes read-only. - Seat limits are enforced on the server against the invitation's allocation, not just in the form.
- Duplicate protection:
- The submit button locks while sending.
- Each form carries an idempotency key, so a retry after a dropped connection is acknowledged rather than applied twice.
- There is one response row per invitation (a unique constraint).
- Rate limits:
- RSVP: 20 per IP and 10 per invitation every 10 minutes.
- Admin login: 5 failed attempts per IP every 15 minutes.
- Returning guests skip the envelope and land on the page with an "Your RSVP" shortcut.
- The public
/page shows no guest data. Its RSVP section explains how to use a personal link.
Admin dashboard (/admin)
- Sign in with the password whose hash is in
ADMIN_PASSWORD_HASH. - Stats: invitations, allocated seats, attending guests, declines and pending replies.
- Invitations: search and filter by status, add or edit (one guest name per line, the first is greeted), generate and copy links, delete.
- Notes: dietary requirements and messages to the couple.
- Export CSV: values that look like spreadsheet formulas are neutralized.
Website editor (/admin/web-edit)
Open it with the Edit website button in the admin menu. Each section saves on its own, and changes appear on the website immediately.
- Wedding details: names, date and time (in the venue's time zone), venue, invitation and closing messages, RSVP deadline, florals on/off.
- Headings: the small titles on the envelope and above each section. Clear one to restore its original text.
- Music: pick or upload the vinyl player's song, or turn the music off.
- Dress code: title, description, color swatches from the palette, attire tips and the unplugged-ceremony note; or hide the section.
- Love story / Schedule: add, edit, reorder (Up/Down) and delete entries. Story photos are picked from, or uploaded straight into, the media library.
- Entourage: add, edit, reorder and delete groups (title, an optional line under it, layout, one name per line). Or import the whole list from Excel (.xlsx), CSV, Word (.docx) or text (.txt): the file is read in the browser and previewed before anything is saved, then you choose to replace the list or add to it. Sample templates in every format (with the import rules) can be downloaded from the section.
- Media library: upload JPG/PNG/WebP/AVIF photos (10 MB) and MP3/M4A/OGG audio (25 MB), copy links, delete. Files the site still uses can't be deleted.
Uploads are checked by their actual bytes (never the file name), stored under random names in MEDIA_PATH and served from /media/….
How the admin is protected:
proxy.tsredirects requests without a valid session early.- Every admin page, route handler and Server Action verifies the signed session again on the server.
- The session cookie is
httpOnly,SameSite=Strict,Securein production, and lasts 8 hours.
Testing locally
- Run
pnpm test. - Run
pnpm db:setup && pnpm build && pnpm start. - Open one of the printed invitation links:
- Break the seal and you'll be greeted by name.
- Scroll to the RSVP and submit once with nothing selected to see validation.
- Accept, then reload. The envelope is skipped and your confirmation is shown.
- Open
/admin, sign in, create an invitation, copy its link, then rotate it and check the old link now shows "We couldn't find it". - Open Edit website, change a heading, upload a photo to a new love-story moment, save, and reload
/. - With reduced motion on (DevTools → Rendering →
prefers-reduced-motion: reduce), all content should show without animations.
Deploying
This app writes to SQLite through better-sqlite3, so it needs one long-running Node.js server with a persistent disk: a VPS, Docker with a volume, or Fly.io/Railway/Render with a volume. It won't work on serverless or edge platforms, where the filesystem is ephemeral or read-only. The in-memory rate limiter also assumes a single instance.
- Set the environment variables, with
DATABASE_PATHandMEDIA_PATHon the persistent volume. Serve over HTTPS, because the admin cookie isSecurein production. - Run
pnpm install --frozen-lockfile && pnpm db:deploy. It applies migrations and seeds sample content only into a database that has no website content yet, so content edited in/admin/web-editand deleted sample invitations are never touched again. - Run
pnpm db:setup && pnpm build && pnpm start. The build needs a seeded database because/is prerendered. - If you're behind a reverse proxy, make sure it overwrites
X-Forwarded-For, since rate limiting keys on it.
Docker + Forgejo CI/CD
Dockerfile builds a small Alpine image (Next.js standalone output, Node binary only, no npm/yarn) that runs as an unprivileged user. On start it runs db-deploy.cjs (a bundled pnpm db:deploy) and then the server. The build prerenders / from a throwaway seeded database; that page is removed from the image, so the first request renders it from the production database.
compose.yaml runs it with a named volume at /app/data (database + uploads), a healthcheck, and the port bound to 127.0.0.1:3000 for your HTTPS reverse proxy.
One-time server setup:
sudo mkdir -p /opt/wedding && sudo chown deploy: /opt/wedding # the deploy user must be in the docker group
cp compose.env.example /opt/wedding/.env && chmod 600 /opt/wedding/.env # then fill it in
docker login <your-forgejo-host> # token with package:read, so the server can pull the image
.forgejo/workflows/ci-cd.yml lints, type-checks and tests in parallel with the image build. On main it pushes :sha-<commit>, deploys it over SSH (docker compose up --wait, with automatic rollback to the previous image if the healthcheck fails), and then tags it :latest. v* tags also publish :<tag>. The required secrets, variables and runner setup are listed at the top of the workflow.
To roll back by hand, set WEDDING_IMAGE in /opt/wedding/.env to an older :sha-… tag and run docker compose up -d. Migrations only add tables and nullable columns, so older releases keep working with a newer database.
Back up the volume while running: docker compose exec web node -e "require('better-sqlite3')('/app/data/wedding.db').backup('/app/data/backup.db')", then copy backup.db and uploads/ off the volume (e.g. docker compose cp web:/app/data ./backup).
SQLite runs in WAL mode with synchronous=NORMAL and foreign keys on. To back up a live database, copy it with sqlite3 wedding.db ".backup backup.db" (not cp, which can catch it mid-write). Back up the MEDIA_PATH folder with it.
Customising
- Content: wedding details, love story, schedule, entourage, dress code, deadline and closing message live in SQLite. Edit
lib/db/seed.tsand re-runpnpm db:seed. Public content is cached for an hour (tagswedding-config,love-story,wedding-events,entourage). - Music: drop an MP3 into
public/assets/music/and setmusic: { src, title }inlib/db/seed.ts, thenpnpm db:seed. Usemusic: nullto remove the player. The file loads only when a guest presses play. - Photos: replace
public/assets/photos/story-0N.webp(seepublic/assets/README.md). - Colors:
styles/theme.cssis the only file that contains color values.