19 KiB
Wedding Website Bootstrap Plan
Goal
Build the dynamic foundation for a wedding website before final visual/content design is complete.
The website should support:
- A public wedding homepage with static sections and personalized guest content.
- Unique invitation links for each invited group/household.
- RSVP and per-person attendance/meal collection.
- Optional guest contributions such as song requests, toast requests, messages, and one photo upload.
- A private
/adminportal restricted to the couple through Google login. - A Docker Compose runtime with PostgreSQL and MinIO.
Confirmed decisions
Application stack
- Framework: Next.js App Router.
- Database: PostgreSQL.
- Query layer: Kysely, no ORM.
- Authentication: Google OAuth.
- Admin access: restricted by email allowlist from
.env. - Object storage: MinIO using S3-compatible APIs.
- Deployment/runtime style: Docker Compose.
- Admin UI component system: shadcn/ui only.
Admin UI requirement
All UI components used in the admin area must come from shadcn/ui.
This applies to every admin page under:
/admin
/admin/*
The admin area must use shadcn/ui components for:
- layout primitives where applicable
- cards
- buttons
- forms
- inputs
- selects
- checkboxes
- dialogs
- dropdown menus
- tables
- badges
- tabs
- alerts
- toasts/sonner notifications
- action menus
- empty states where applicable
- loading/skeleton states
Custom admin UI should only be composed from shadcn/ui components and small local wrappers around them. Do not introduce another component library for the admin portal.
If a needed admin component is not yet installed from shadcn/ui, add it through the shadcn workflow and then reuse it consistently.
The guest-facing invitation pages may have custom wedding-themed design, but the admin management UI must remain shadcn-based.
Invitation model
- The primary guest-facing unit is an
Invitation, not an individual guest. - An invitation represents a household, couple, family, individual guest, or group.
- Each invitation has one unique private token and one invite URL:
/invite/[token]
- Each invitation must have a required language.
- The invitation language is the source of truth for which language the guest-facing invitation page uses.
- This is a core requirement because many guests may only speak one of the wedding languages.
- Admins choose the invitation language when creating or editing an invitation.
- Admin pages can remain in the couple's preferred management language initially, but guest-facing copy must respect the invitation language.
Invitation members
- Each invitation contains one or more
InvitationMemberrecords. - Members are modeled from the start because attendance and food choices can differ per person.
- Per-person attendance lives on
invitation_members.attending. - The response table must not duplicate attendance status.
RSVP/response model
invitation_membersowns per-person answers:- attending
- meal preference
- dietary restrictions
invitation_responsesowns group-level logistics:- submitted timestamp
- travel dates
- stay location
- transport help
- message to the couple
- RSVP edits are allowed until a configured deadline.
Photo uploads
- One image per invitation/group.
- The image may be replaced until the deadline.
- Store files in MinIO.
- Store object metadata in PostgreSQL.
Optional wedding interactions
Include these from the bootstrap if feasible:
- Song requests.
- Optional toast/speech request.
- Advice, wish, or favorite memory message.
Proposed environment variables
DATABASE_URL=postgres://wedding:wedding@localhost:5432/wedding
AUTH_SECRET=change-me
AUTH_GOOGLE_ID=change-me
AUTH_GOOGLE_SECRET=change-me
ADMIN_EMAILS=person-one@example.com,person-two@example.com
APP_URL=http://localhost:3000
RSVP_DEADLINE=2026-08-01
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=wedding-uploads
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true
Target folder structure
app/
page.tsx
invite/
[token]/
page.tsx
admin/
page.tsx
guests/
page.tsx
new/
page.tsx
[id]/
page.tsx
api/
auth/
[...nextauth]/
route.ts
uploads/
presign/
route.ts
complete/
route.ts
lib/
auth.ts
config.ts
db/
client.ts
types.ts
queries/
invitations.ts
members.ts
responses.ts
uploads.ts
song-requests.ts
toast-requests.ts
guest-messages.ts
storage/
s3.ts
uploads.ts
i18n/
config.ts
dictionaries/
en.ts
es.ts
de.ts
validation/
invitation.ts
response.ts
upload.ts
interactions.ts
scripts/
migrate-sql.mjs
migrations/
0001_initial_schema.sql
docker-compose.yml
plans/
wedding-bootstrap-plan.md
Database schema plan
invitations
Represents one invite/group/household.
id uuid primary key
token text unique not null
display_name text not null
language text not null
contact_email text
contact_phone text
max_party_size integer not null default 1
allow_member_edits boolean not null default false
notes text
created_at timestamptz not null
updated_at timestamptz not null
Notes:
tokenmust be unguessable.display_nameis what the guest sees, e.g.Ana & LuisorThe Garcia Family.languagecontrols guest-facing page copy and form labels for this invitation.- Recommended initial language values should be short locale codes such as
en,es,de, etc. allow_member_editsallows flexible +1 or family edits later.
invitation_members
Represents each individual person under an invitation.
id uuid primary key
invitation_id uuid not null references invitations(id) on delete cascade
name text not null
is_primary_contact boolean not null default false
attending boolean
meal_preference text
dietary_restrictions text
notes text
created_at timestamptz not null
updated_at timestamptz not null
Notes:
attending = nullmeans pending/unanswered.- Attendance status is derived from this table, not from
invitation_responses. - Meal preference examples:
meat,fish,vegetarian,vegan,kids,other.
invitation_responses
Stores group-level RSVP metadata and logistics.
id uuid primary key
invitation_id uuid unique not null references invitations(id) on delete cascade
submitted_at timestamptz
arrival_date date
departure_date date
stay_location text
coming_from text
needs_transport_help boolean
message_to_couple text
created_at timestamptz not null
updated_at timestamptz not null
Notes:
- No attendance status here.
- No attendee count here.
- Admin dashboard derives totals from
invitation_members.
photo_uploads
Stores one uploaded activity/surprise image per invitation.
id uuid primary key
invitation_id uuid unique not null references invitations(id) on delete cascade
bucket text not null
object_key text not null
original_filename text
content_type text
size_bytes integer
created_at timestamptz not null
updated_at timestamptz not null
Notes:
- Unique
invitation_idenforces one photo per invitation. - Replacing a photo updates this record and may delete/overwrite the old object.
song_requests
Stores optional party song requests.
id uuid primary key
invitation_id uuid not null references invitations(id) on delete cascade
title text not null
artist text
note text
created_at timestamptz not null
Notes:
- Recommended limit: up to 3 song requests per invitation.
toast_requests
Stores optional requests to say a few words.
id uuid primary key
invitation_id uuid not null references invitations(id) on delete cascade
member_id uuid references invitation_members(id) on delete set null
speaker_name text not null
estimated_minutes integer
note text
status text not null default 'pending'
created_at timestamptz not null
updated_at timestamptz not null
Recommended statuses:
pending
accepted
declined
scheduled
Notes:
- Toast requests are voluntary.
- Admin approval is required before scheduling.
guest_messages
Stores optional advice, wishes, or memories.
id uuid primary key
invitation_id uuid not null references invitations(id) on delete cascade
type text not null
message text not null
created_at timestamptz not null
Recommended message types:
advice
memory
wish
admin_audit_events optional
Useful but not required for the first pass.
id uuid primary key
admin_email text not null
action text not null
target_type text
target_id uuid
metadata jsonb
created_at timestamptz not null
Derived RSVP status logic
Admin-facing status should be computed instead of stored redundantly.
Suggested derived statuses:
Pending
invitation_response.submitted_at is null
Partially answered
submitted_at exists and at least one member has attending = null
Attending
at least one member has attending = true and no members are pending
Declined
all members have attending = false and no members are pending
Partial
at least one member attending = true and at least one member attending = false
Dashboard metrics can derive:
- total invitations
- total invited members
- attending members
- declined members
- pending members
- invitations with submitted responses
- invitations with uploaded photos
- song request count
- pending toast requests
Guest-facing invite flow
Route:
/invite/[token]
Flow:
- Resolve invitation by token.
- If token is invalid, show a friendly invalid-invitation page.
- If valid, render personalized wedding page shell.
- Load guest-facing copy based on
invitations.language. - Show static wedding information:
- hero
- date
- countdown
- location
- accommodation information
- short couple story placeholder
- Show RSVP section with all invitation members.
- For each member:
- attending yes/no
- meal preference if attending
- dietary restrictions if attending
- Show group logistics:
- arrival date
- departure date
- stay location
- coming from
- transport help
- message to the couple
- Show optional interaction sections:
- song requests
- toast/speech request
- advice/wish/memory
- one photo upload
- Before deadline, allow edits.
- After deadline, show submitted answers read-only and explain that changes require contacting the couple.
Guest-facing localized content should include at least:
- page headings
- navigation/section labels
- RSVP form labels
- validation messages
- success/error messages
- deadline/read-only messages
- upload instructions
- optional interaction prompts
Wedding factual content, such as venue names and addresses, can remain shared, but surrounding explanatory copy should be localized.
Admin portal plan
Admin routes:
/admin
/admin/guests
/admin/guests/new
/admin/guests/[id]
Although the domain model is invitations, the UI can still use guest-friendly labels like Guests.
/admin
Dashboard cards:
- Total invitations.
- Total invited people.
- RSVP pending.
- Attending people.
- Declined people.
- Uploaded photos.
- Song requests.
- Pending toast requests.
/admin/guests
Invitation list table:
- display name
- language
- contact email
- contact phone
- members count
- derived RSVP status
- attending count
- invite link
- last updated
Actions:
- copy invite link
- view details
- edit
/admin/guests/new
Create invitation form:
- display name
- language
- contact email
- contact phone
- max party size
- allow member edits
- notes
- initial members
On submit:
- generate token
- create invitation
- create initial members
- show generated invite URL
The language field is required.
/admin/guests/[id]
Detail page:
- invitation metadata
- invite token/link
- member list and per-person RSVP answers
- group logistics
- uploaded photo preview/link
- song requests
- toast requests with admin status controls
- advice/wish/memory messages
- admin notes
Admin actions:
- edit invitation
- add/edit/remove members if needed
- regenerate invite token
- manually adjust RSVP/member data
- accept/decline/schedule toast request
Authentication plan
Use Google OAuth with an email allowlist.
Implementation targets:
auth.ts
app/api/auth/[...nextauth]/route.ts
middleware.ts
Rules:
- Only emails listed in
ADMIN_EMAILSmay access/adminand/admin/*. - Non-admin Google users are rejected or shown a forbidden page.
- Guest invite pages do not require login.
- Admin emails remain in
.envfor the bootstrap.
Before implementation, verify the exact current Next.js/Auth.js setup for the installed framework version.
MinIO upload plan
Use AWS SDK S3-compatible client.
Upload flow:
- Guest selects one image.
- Client asks the app for a presigned upload URL.
- App validates invitation token and deadline.
- App creates an object key:
invitations/{invitationId}/{uuid}-{safeFilename}
- Browser uploads directly to MinIO using presigned PUT.
- Client calls complete endpoint.
- App upserts
photo_uploadsmetadata for the invitation.
Validation:
- Allow only image MIME types:
image/jpegimage/pngimage/webp
- Enforce a max size.
- Enforce one current photo per invitation.
Admin viewing:
- Generate short-lived signed GET URLs for previews/downloads.
Docker Compose plan
Services:
app
postgres
minio
minio-init
Postgres:
- expose
5432 - database:
wedding - user:
wedding - password:
wedding
MinIO:
- API:
9000 - console:
9001 - bucket:
wedding-uploads
minio-init:
- wait for MinIO
- create bucket if it does not exist
Implementation sequence
Step 1: Review framework-specific docs
Because this project uses Next.js 16, review the locally installed Next documentation before coding framework-specific pieces.
Focus areas:
- App Router route handlers.
- Server actions/forms.
- Middleware behavior.
- Authentication integration compatibility.
Step 2: Add dependencies
Install likely packages:
npm install kysely pg zod next-auth @aws-sdk/client-s3 @aws-sdk/s3-request-presigner nanoid
npm install -D @types/pg kysely-codegen
Adjust if current package versions require different auth package names or setup.
Step 3: Add environment handling
- Create
.env.example. - Add typed config helper in
lib/config.ts. - Validate required env vars at startup/server usage.
Step 4: Add Docker Compose
- Add
docker-compose.yml. - Define Postgres service.
- Define MinIO service.
- Define MinIO bucket initialization.
- Optionally define app service.
Step 5: Add Kysely database client
- Create
lib/db/client.ts. - Create initial manual DB types in
lib/db/types.ts. - Add SQL migration runner in
scripts/migrate-sql.mjs. - Add package script:
"db:migrate": "node scripts/migrate-sql.mjs"
Step 6: Create initial SQL migration
Create migrations/0001_initial_schema.sql for:
invitationsinvitation_membersinvitation_responsesphoto_uploadssong_requeststoast_requestsguest_messages- optional
admin_audit_events
Step 7: Add query modules
Create query helpers for:
- invitations
- invitation members
- invitation responses
- uploads
- song requests
- toast requests
- guest messages
Keep SQL explicit and typed through Kysely.
Step 8: Add validation schemas
Use Zod for:
- creating/updating invitations
- creating/updating members
- submitting RSVP/member attendance
- submitting logistics
- song requests
- toast requests
- guest messages
- upload presign/complete requests
Invitation validation must require a supported language code.
Step 9: Implement Google admin auth
- Configure Google provider.
- Restrict sign-in to
ADMIN_EMAILS. - Protect
/adminroutes. - Add sign-in/sign-out UI as needed.
Step 10: Build admin MVP
Implement:
/admindashboard./admin/guestsinvitation list./admin/guests/newcreate form./admin/guests/[id]detail page.
Minimum admin operations:
- create invitation
- add members
- copy invite link
- view submitted RSVP data
- view optional guest interactions
Step 11: Build guest invite MVP
Implement /invite/[token] with:
- invalid token handling
- personalized greeting
- language-aware guest-facing copy based on
invitations.language - wedding info shell
- countdown
- member attendance form
- per-member meal/dietary fields
- group logistics form
- submission/edit behavior until deadline
Step 12: Add optional guest interactions
Add form sections for:
- up to 3 song requests
- optional toast/speech request
- advice/wish/memory message
Admin should be able to view all of them.
Step 13: Add MinIO photo upload
Implement:
- S3 client.
- presigned PUT endpoint.
- upload complete endpoint.
- one-photo-per-invitation metadata upsert.
- admin signed preview/download URL.
Step 14: Add basic design shell
Replace default starter UI with a simple wedding-appropriate layout:
- elegant typography
- hero section
- countdown
- location/accommodation placeholders
- story placeholder
- clear guest forms
- responsive mobile-first layout
Final visual design can remain future work.
Step 14.5: Add localization foundation
Add a simple localization layer for guest-facing invitation pages.
Recommended bootstrap approach:
- Keep supported language codes in config.
- Store the selected language on each invitation.
- Resolve guest-facing copy from
invitations.languageon/invite/[token]. - Create translation dictionaries for guest-facing copy.
- Fallback to a default language only if necessary, but admin should always select a supported language.
Suggested structure:
lib/
i18n/
config.ts
dictionaries/
en.ts
es.ts
de.ts
Do not rely only on browser language detection for invited guests. The invitation language is the source of truth.
Step 15: Verification
Run:
npm run lint
npm run build
Manual happy path:
- Start Docker Compose.
- Run migrations.
- Log in as admin with allowed Google account.
- Create an invitation with multiple members.
- Set the invitation language.
- Copy invite link.
- Open invite link.
- Confirm the page renders in the invitation's configured language.
- Submit attendance with different meal preferences per member.
- Add travel details.
- Add song request, toast request, and message.
- Upload one photo.
- Return to admin and confirm all data is visible.
- Test invalid invite token.
- Test non-admin access to
/admin. - Test deadline read-only behavior.
Open implementation questions
These can be decided during implementation:
- Should guests be able to add/edit member names when
allow_member_edits = true, or only fill unnamed placeholders? - What should the exact RSVP deadline be?
- What max upload size should be allowed?
- Should admin be able to export guest/RSVP data as CSV in the first version?
- Which exact languages should be supported in the first version?
Recommended first milestone
The first useful milestone should be:
- Docker Compose with Postgres and MinIO.
- Initial pure SQL migration executed by
scripts/migrate-sql.mjs. - Google admin login restricted by
.envemails. - Admin can create invitation with members.
- Admin can select a language per invitation.
- Guest can open
/invite/[token]and submit per-member RSVP answers. - Guest invite page renders in the invitation's configured language.
- Admin can see submitted answers.
After that, add photo uploads and the fun interaction sections.