Go Lambda API + React SPA for wedding RSVPs. One invite can include multiple guests.
- Backend: Go container image on AWS Lambda
- Frontend: React SPA on S3 + CloudFront
- Database: Remote MySQL (
invites,gueststables — populated separately)
Go Lambda API backed by MySQL, with a React SPA served through CloudFront (S3 is the origin only — browsers do not hit S3 directly). Guests open personalized invite links; admins manage invites through a separate SPA entry point.
| Layer | Technology | Role |
|---|---|---|
| Frontend | React 18 + Vite | Guest landing page, RSVP modal, admin UI |
| CDN / hosting | CloudFront → S3 | CloudFront serves the SPA on the custom domain; S3 is the private origin |
| API | API Gateway HTTP API | Routes to Lambda, CORS |
| Compute | AWS Lambda (container) | Single Go handler for all routes |
| Database | MySQL | invites and guests tables |
| Deploy | Makefile + GitHub Actions | OIDC to AWS on PR merge |
More detail: docs/architecture.md.
Base URL: your API Gateway URL, e.g. https://abc123.execute-api.ap-southeast-1.amazonaws.com
| Method | Path | Description |
|---|---|---|
GET |
/guest?id={invite_id} |
Load invite and all guests |
POST |
/guest |
Save RSVP (guest update, invite update, or decline all — see body shape below) |
OPTIONS |
/guest |
CORS preflight |
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/admin/login |
None | Sign in; returns bearer token |
OPTIONS |
/admin/login |
None | CORS preflight |
GET |
/admin/invites |
Bearer | List all invites with guests and RSVP responses |
GET |
/admin/invites?id={invite_id} |
Bearer | Get one invite |
POST |
/admin/invites |
Bearer | Create invite and guests |
PATCH |
/admin/invites |
Bearer | Update invite (is_sent) or guest name (guest_id + name) |
DELETE |
/admin/invites?id={invite_id} |
Bearer | Delete invite and its guests |
OPTIONS |
/admin/invites |
None | CORS preflight |
PATCH |
/admin/guests |
Bearer | Update a guest's name |
OPTIONS |
/admin/guests |
None | CORS preflight |
Admin requests (except login) send Authorization: Bearer {token} from POST /admin/login. Credentials are ADMIN_USERNAME and ADMIN_PASSWORD on Lambda.
Load an invite and its guests by snowflake invite id.
Query parameters
| Param | Required | Description |
|---|---|---|
id |
yes | Invite id (snowflake) |
Example
curl "https://YOUR_API_URL/guest?id=1234567890123456789"200 OK
{
"id": "1234567890123456789",
"require_parking": true,
"last_updated": "2026-06-01",
"guests": [
{
"id": 1,
"name": "Jane Doe",
"is_attending": true,
"attend_solemnisation": true,
"dietary_restriction": "Vegetarian",
"last_updated": "2026-06-01"
},
{
"id": 2,
"name": "John Doe"
}
]
}Null fields (require_parking, is_attending, attend_solemnisation, dietary_restriction, last_updated) are omitted from the response.
Errors
| Status | Body | Cause |
|---|---|---|
400 |
{"error":"id is required"} |
Missing id query param |
404 |
{"error":"invite not found"} |
Invite id not in database |
500 |
{"error":"failed to load invite"} |
Database error |
Save RSVP data. The handler routes by request body:
- Body contains
"decline_all": true→ decline all guests for the invite - Body contains
require_parking→ invite update - Otherwise → guest update
RSVP submissions are rejected with 403 at or after 11 September 2026 00:00 (Asia/Singapore). GET /guest remains available so guests can still open their invite link.
Request body
{
"id": "1234567890123456789",
"decline_all": true
}| Field | Required | Description |
|---|---|---|
id |
yes | Invite id (snowflake, string) |
decline_all |
yes | Must be true — marks every guest on the invite as not attending |
200 OK — returns the updated invite with guests (all is_attending: false, dietary_restriction: "", attend_solemnisation: null). Any unset invite booleans are saved as false.
Request body
{
"id": "1234567890123456789",
"require_parking": true
}| Field | Required | Description |
|---|---|---|
id |
yes | Invite id (snowflake, string) |
require_parking |
yes | Whether couple parking is required |
Example
curl -X POST "https://YOUR_API_URL/guest" \
-H "Content-Type: application/json" \
-d '{"id":"1234567890123456789","require_parking":true}'200 OK — returns the updated invite with guests (same shape as GET).
Errors
| Status | Body | Cause |
|---|---|---|
403 |
{"error":"rsvp has closed"} |
RSVP cutoff passed (11 Sep 2026 00:00 Asia/Singapore) |
400 |
{"error":"id is required"} |
Missing invite id |
400 |
{"error":"require_parking is required"} |
Missing parking field |
404 |
{"error":"invite not found"} |
Invite id not in database |
Request body
{
"id": 1,
"is_attending": true,
"attend_solemnisation": true,
"dietary_restriction": "Vegetarian"
}| Field | Required | Description |
|---|---|---|
id |
yes | Guest id (integer) |
is_attending |
yes | true = attending, false = declining |
attend_solemnisation |
yes* | Whether attending the solemnisation |
dietary_restriction |
no | Dietary needs; omit or send "" if none (stored as empty string, never null) |
* Required when is_attending is true. Cleared to null when declining.
After any save, nullable RSVP fields are persisted as concrete values: booleans as true/false, text as "".
Example
curl -X POST "https://YOUR_API_URL/guest" \
-H "Content-Type: application/json" \
-d '{"id":1,"is_attending":true,"attend_solemnisation":true,"dietary_restriction":"Vegetarian"}'200 OK
{ "status": "saved" }Errors
| Status | Body | Cause |
|---|---|---|
403 |
{"error":"rsvp has closed"} |
RSVP cutoff passed (11 Sep 2026 00:00 Asia/Singapore) |
400 |
{"error":"id is required"} |
Missing guest id |
400 |
{"error":"is_attending is required"} |
Missing attendance choice |
400 |
{"error":"attend_solemnisation is required when attending"} |
Attending guest missing solemnisation choice |
404 |
{"error":"guest not found"} |
Guest id not in database |
Request body
{
"username": "admin",
"password": "your-password"
}200 OK
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expires_at": "2026-06-03T12:00:00Z"
}| Status | Body | Cause |
|---|---|---|
401 |
{"error":"invalid username or password"} |
Wrong credentials |
503 |
{"error":"admin login is not configured"} |
ADMIN_USERNAME or ADMIN_PASSWORD not set on Lambda |
List every invite with guests and RSVP fields (is_sent, require_parking, is_attending, attend_solemnisation, dietary_restriction, etc.).
Example
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://YOUR_API_URL/admin/invites"200 OK — JSON array of invite objects.
Get one invite — same path with ?id={invite_id}.
Create a new invite. The server generates a snowflake id.
Request body
{
"guests": ["Jane Doe", "John Doe"],
"is_sent": false
}| Field | Required | Description |
|---|---|---|
guests |
yes | Guest names (at least one) |
is_sent |
no | Whether the invite link was sent |
201 Created
{
"invite": {
"id": "1234567890123456789",
"is_sent": false,
"guests": [{ "id": 1, "name": "Jane Doe" }]
}
}Update invite metadata or a guest's name.
Invite update — set is_sent:
{
"id": "1234567890123456789",
"is_sent": true
}Guest name update — set guest_id and name (RSVP fields are not changed). Include id (invite snowflake id) and previous_name (current guest name) so the server can resolve the guest if guest_id is stale. Rejected with 409 Conflict once any guest on the same invite has submitted an RSVP (is_attending is set):
{
"id": "1234567890123456789",
"guest_id": 1,
"name": "Jane Smith",
"previous_name": "Jane"
}| Field | Required | Description |
|---|---|---|
id |
yes (invite update); recommended (name update) | Invite snowflake id |
is_sent |
yes (invite update) | Whether the invite was sent |
guest_id |
yes (name update) | Guest id |
name |
yes (name update) | New guest name (non-empty after trim) |
previous_name |
recommended (name update) | Current guest name; used to find the guest when guest_id no longer matches |
200 OK — updated invite object, updated guest object (name update), or {"status":"saved"}.
404 Not Found — {"error":"invite not found"} or {"error":"guest not found"}.
409 Conflict — {"error":"guest names cannot be changed after an RSVP response"} (name update only).
Example (guest name)
curl -X PATCH -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"1234567890123456789","guest_id":1,"name":"Jane Smith","previous_name":"Jane"}' \
"https://YOUR_API_URL/admin/invites"Update a guest's name. Same behaviour as PATCH /admin/invites with guest_id and name. Prefer the /admin/invites path in production — it uses an existing API Gateway route.
Request body
{
"id": 1,
"name": "Jane Smith"
}| Field | Required | Description |
|---|---|---|
id |
yes | Guest id |
name |
yes | New guest name (non-empty after trim) |
200 OK — updated guest object.
404 Not Found — {"error":"guest not found"}.
409 Conflict — {"error":"guest names cannot be changed after an RSVP response"}.
Example
curl -X PATCH -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"Jane Smith"}' \
"https://YOUR_API_URL/admin/guests"Query parameters
| Param | Required | Description |
|---|---|---|
id |
yes | Invite id to delete |
Example
curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \
"https://YOUR_API_URL/admin/invites?id=1234567890123456789"200 OK — {"status":"deleted"}.
- AWS CLI configured with deploy permissions
- Docker with
buildx - ECR repository created
- Lambda function created (container image,
x86_64, 256 MB, 30 s timeout) - HTTP API Gateway with routes pointing to the Lambda
- S3 bucket + CloudFront distribution for the frontend
- MySQL database with
invitesandgueststables
Set these in the AWS Lambda console (or your IaC):
| Variable | Example | Description |
|---|---|---|
ENV |
prod |
Must be lowercase prod for production CORS |
DB_HOST |
your-db.example.com |
MySQL host |
DB_PORT |
3306 |
MySQL port |
DB_USER |
rsvp_user |
MySQL user |
DB_PASSWORD |
*** |
MySQL password |
DB_NAME |
rsvp |
Database name |
FRONTEND_ORIGIN |
https://alvinandvivian.rsvp |
Primary site origin for CORS (no trailing slash) |
FRONTEND_ORIGINS |
(optional) | Extra allowed origins, comma-separated (e.g. CloudFront URL during migration) |
ADMIN_USERNAME |
admin |
Admin login username |
ADMIN_PASSWORD |
*** |
Admin login password |
ADMIN_TOKEN_SECRET |
(optional) | Signs session tokens; defaults to ADMIN_PASSWORD if unset |
If MySQL is in a VPC, attach the Lambda to the same VPC/subnets/security groups and add the AWSLambdaVPCAccessExecutionRole policy.
# API: build Docker image → push to ECR → update Lambda
make deploy-api
# Frontend: build React app → sync to S3 → invalidate CloudFront
make deploy-frontend
# Both
make deployWhen you add a new API path, create the matching HTTP API Gateway route in the AWS console (or run make api-gateway-routes locally with credentials that have apigateway:GET and apigateway:POST on the API). The GitHub Actions deploy role does not include API Gateway permissions.
cp api/.env.example api/.env
# edit with DB credentials and TEST_INVITE_ID
cd api && go run ./cmd/localcp frontend/.env.example frontend/.env
cd frontend && npm install && npm run devOpen http://localhost:5173/?id=YOUR_INVITE_ID.
Admin UI: http://localhost:5173/admin.html — see Admin UI (set ADMIN_USERNAME and ADMIN_PASSWORD in api/.env when running the API locally).
To proxy API calls through Vite during dev, set VITE_API_PROXY_TARGET in frontend/.env to your API Gateway URL and leave VITE_API_BASE_URL empty. The dev server proxies /guest and /admin to that target.
make testPassword-protected admin app at /admin.html on the deployed site (e.g. https://alvinandvivian.rsvp/admin.html) or locally at http://localhost:5173/admin.html. It uses the admin API (POST /admin/login, /admin/invites, /admin/guests) documented above.
Sign in with ADMIN_USERNAME and ADMIN_PASSWORD (Lambda env vars, or api/.env when running the API locally). The session token is stored in localStorage and remains valid for 24 hours (see expires_at from POST /admin/login), including across browser restarts on the same device.
| Area | Actions |
|---|---|
| Create invite | Enter guest names (one per line); the server assigns a snowflake id and the RSVP link is copied to the clipboard after create |
| Invite list | Search by guest name; see counts of invites and guests |
| Per invite | QR code for the RSVP link, Copy link, Copy message (uses the template below), Mark sent / Mark unsent, Delete, Responses (expand RSVP table) |
| Guest names | In Responses, use Edit next to a guest name to rename them (disabled once any guest on that invite has responded) |
| Message template | Edit message — customize the WhatsApp/SMS-style invite text stored in localStorage; placeholders [Names] and [Link] are replaced per invite |
| Export | Download CSV — one row per guest with invite id, sent/parking flags, attendance, solemnisation, dietary needs, and timestamps |
| Session | Sign out clears the stored token |
Expanded Responses shows attending, solemnisation, dietary, and last-updated per guest. Guest names can be edited inline from this table until any guest on the invite has responded. Summary lines show responded/attending counts plus sent and parking status.
Captured with mocked invite data (npm run screenshots in frontend/; see Project layout). Production data will differ.
Desktop (1280px)
| Invite list | Responses expanded |
|---|---|
![]() |
![]() |
Mobile (~390px)
| Invite list | Responses expanded |
|---|---|
![]() |
![]() |
Database: rsvp (populated separately).
| Column | Type |
|---|---|
id |
snowflake |
is_sent |
boolean |
require_parking |
boolean |
last_updated |
datetime, default NOW() |
| Column | Type |
|---|---|
id |
auto incremental (AUTO_INCREMENT) |
invite_id |
foreign key → invites.id |
name |
text |
dietary_restriction |
text |
is_attending |
boolean |
attend_solemnisation |
boolean |
last_updated |
datetime, default NOW() |
One invite (invites.id) can have many guests (guests.invite_id).
The public API reads and updates require_parking on invites, and attend_solemnisation, is_attending, and dietary_restriction on guests. Admin routes can create/delete invites and update is_sent.
CREATE TABLE invites (
id BIGINT UNSIGNED NOT NULL,
is_sent BOOLEAN NULL,
require_parking BOOLEAN NULL,
last_updated TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE guests (
id INT NOT NULL AUTO_INCREMENT,
invite_id BIGINT UNSIGNED NOT NULL,
name TEXT NOT NULL,
dietary_restriction TEXT NULL,
is_attending BOOLEAN NULL,
attend_solemnisation BOOLEAN NULL,
last_updated TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (invite_id) REFERENCES invites (id)
);.
├── api/ # Go Lambda (handler, store, config)
├── docs/
│ ├── architecture.md # System and component diagrams (Mermaid)
│ └── readme/ # Images committed for README (architecture diagram, admin UI screenshots)
├── frontend/ # React + Vite SPA (`index.html` landing + RSVP popup, `admin.html` admin)
│ └── public/
│ ├── original/ # Source PNG/GIF illustrations (masters)
│ └── images/ # Web-optimized copies (WebP + optimized GIF, sprite metadata)
├── Dockerfile # Lambda container image
├── Makefile # Build and deploy commands
└── INSTRUCTIONS.md # Repo rules — keep README in sync with API/deploy changes
Regenerate UI screenshots locally (written to gitignored docs/screenshots/ by default):
cd frontend
npm ci && npm run build && npx playwright install chromium && npm run screenshotsCopy updated admin images into docs/readme/admin/ when refreshing the README screenshots.
Wedding copy (couple names, date, venue) is in frontend/src/constants.js.




