Turn eating alone into an opportunity for real human connection.
The Quiet Table matches solo diners at restaurants and cafes in real time. Signal you're open to company, the app finds someone nearby who did the same, and you share a meal. No social graph. No followers. No feed. Just a genuine moment with a stranger.
Built for Pearl Hacks.
- How It Works
- Project Structure
- Tech Stack
- Data Models
- Backend API
- Matching Algorithm
- Icebreaker Generation
- Environment Variables
- Getting Started
- Safety Principles
The home screen offers two paths:
- Search for and select a specific restaurant
- Set a time you'll be there
- Enter a waiting screen while the app looks for matches
- When a match is found, both users confirm
- A location hint appears — the matched user describes where they're sitting
- Icebreakers screen with audio + conversation prompts
- A map shows nearby solo diners at venues.
- Filter by preferences (age, gender)
- Tap a venue pin to see available diners, then tap "Match"
- On match confirmation — location hint and icebreakers follow
After the meal, the connection dissolves unless both users choose to exchange contact info.
quiet-table/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout (fonts, providers)
│ │ ├── page.tsx # Home screen (splash + two-path entry)
│ │ ├── globals.css # Global styles + design tokens
│ │ ├── splash/page.tsx # Splash screen (unauthenticated entry)
│ │ ├── login/page.tsx # Google OAuth login
│ │ ├── onboarding/page.tsx # 5-step profile setup
│ │ ├── map/page.tsx # Nearby diners map (Path B)
│ │ ├── venue-picker/page.tsx # Restaurant search + time picker (Path A)
│ │ ├── waiting/page.tsx # "Waiting for matches..." (Path A)
│ │ ├── location-hint/page.tsx # Matched user's seating description
│ │ ├── icebreakers/page.tsx # Audio + conversation prompts
│ │ ├── profile/page.tsx # User settings
│ │ └── api/auth/[...nextauth]/
│ │ └── route.ts # NextAuth route handler
│ ├── components/
│ │ ├── AvatarButton.tsx
│ │ ├── Providers.tsx # NextAuth session provider
│ │ └── ui/ # Reusable UI components
│ ├── lib/
│ │ └── api.ts # Client-side API calls to backend
│ └── types/
│ └── next-auth.d.ts # NextAuth type extensions
│
├── server/ # Express backend
│ ├── index.ts # Entry point
│ ├── config/db.ts # MongoDB connection
│ ├── middleware/auth.ts # JWT auth middleware
│ ├── routes/
│ │ ├── users.ts # Auth, registration, profile
│ │ ├── sessions.ts # Availability signals + matching
│ │ └── icebreakers.ts # Gemini API calls
│ ├── models/
│ │ ├── User.ts
│ │ ├── Session.ts
│ │ ├── Match.ts
│ │ └── Venue.ts
│ ├── services/
│ │ ├── gemini.ts # Icebreaker generation
│ │ └── bandwidth.ts # SMS verification
│ └── scripts/seed.ts # Database seeding
│
├── shared/
│ └── types.ts # Shared TypeScript types
│
├── CLAUDE.md
└── .env
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | Next.js 16 (React 19) | Web app with App Router |
| Styling | Tailwind CSS 4 | Utility-first styling |
| Auth | NextAuth.js v5 (beta) | Google OAuth on the frontend |
| Backend | Node.js + Express | API server |
| Database | MongoDB Atlas + Mongoose | Users, sessions, matches, venues |
| Maps | Google Maps API + Places | Map view and venue search |
| AI | Gemini API | Icebreaker generation |
| Audio | ElevenLabs | Spoken icebreaker (optional) |
| SMS | Bandwidth | Phone verification + notifications |
{
_id: ObjectId,
phone: string, // Verified via Bandwidth SMS OTP
alias: string, // Display name shown to other users
legalName: string, // Private — never surfaced to other users
preferences: {
openTo: "1-on-1" | "group" | "both",
starterOnly: boolean,
},
flags: number, // Silent report counter
active: boolean,
createdAt: Date
}{
_id: ObjectId,
userId: ObjectId,
venueId: ObjectId,
location: {
type: "Point",
coordinates: [lng, lat]
},
status: "available" | "matched" | "ended",
mode: "specific" | "open",
openTo: "1-on-1" | "group",
starterOnly: boolean,
locationHint?: string,
scheduledTime?: Date,
expiresAt: Date,
createdAt: Date
}{
_id: ObjectId,
sessionIds: ObjectId[],
userIds: ObjectId[],
venueId: ObjectId,
icebreaker: string,
icebreakerAudioUrl?: string,
status: "pending" | "confirmed" | "active" | "ended",
contactExchanged: boolean[],
createdAt: Date,
endedAt?: Date
}{
_id: ObjectId,
name: string,
address: string,
location: { type: "Point", coordinates: [lng, lat] },
partnerStatus: "active" | "inactive",
staffNotified: boolean,
createdAt: Date
}Base URL: http://localhost:3001/api
| Method | Endpoint | Description |
|---|---|---|
POST |
/users/register |
Register with phone number, trigger SMS OTP |
POST |
/users/verify |
Verify OTP, receive JWT |
GET |
/users/me |
Get current user profile |
PATCH |
/users/me |
Update alias, preferences |
POST |
/users/report/:userId |
Silently flag another user |
| Method | Endpoint | Description |
|---|---|---|
POST |
/sessions |
Create a new availability signal |
GET |
/sessions/nearby |
Get nearby available sessions |
PATCH |
/sessions/:id/join |
Express interest in joining a session |
PATCH |
/sessions/:id/end |
End your current session |
| Method | Endpoint | Description |
|---|---|---|
GET |
/matches/:id |
Get match details including icebreaker |
PATCH |
/matches/:id/exchange |
Opt in to contact exchange |
PATCH |
/matches/:id/end |
End the match session |
Located in server/services/matching.ts.
- User creates a Session with their current location at a venue
- Backend queries MongoDB for other active sessions within 500m using
$nearSphere - Candidates are filtered by compatibility and status
- Candidates appear on the map as available diners
- Mutual match required — both users must opt in before either sees the other's alias
- On mutual confirmation, a Match document is created and icebreaker generation triggers
Required MongoDB indexes:
db.sessions.createIndex({ location: "2dsphere" })
db.venues.createIndex({ location: "2dsphere" })
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })Located in server/services/gemini.ts. When a match is confirmed, the Gemini API generates a single warm, curious conversation starter based on the venue context — one genuine question, not a list.
If ELEVENLABS_API_KEY is set, the icebreaker is also converted to audio via ElevenLabs.
Create .env files in both the project root and server/. Never commit these.
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
NEXT_PUBLIC_BACKEND_URL=http://localhost:3001MONGODB_URI=
GEMINI_API_KEY=
ELEVENLABS_API_KEY=
ELEVENLABS_VOICE_ID=
BANDWIDTH_ACCOUNT_ID=
BANDWIDTH_USERNAME=
BANDWIDTH_PASSWORD=
BANDWIDTH_FROM_NUMBER=
JWT_SECRET=
PORT=3001- Node.js 18+
- MongoDB Atlas account (free tier works)
- Google Cloud project with Maps API + OAuth credentials
- Gemini API key
# Clone the repo
git clone https://github.com/your-org/quiet-table.git
cd quiet-table
# Install frontend dependencies
npm install
# Install backend dependencies
cd server && npm install && cd ..
# Add your .env.local (root) and server/.env (see above)
# Start the backend (in one terminal)
cd server && npx ts-node-dev index.ts
# Start the frontend (in another terminal)
npm run devThe frontend runs at http://localhost:3000 and the backend at http://localhost:3001.
Safety is a design constraint, not a feature:
- No location data retained — coordinates are used for matching only, not logged after a session ends
- Alias only — legal name is never included in any API response to another user
- Mutual match required — neither user sees the other's alias until both opt in
- Silent flagging — reports increment a counter; removal is automatic at threshold
- Session TTL — all sessions expire after 90 minutes via MongoDB TTL index
- Public venues only — session creation is rejected at unverified venues
- Rate limiting — max 3 active sessions per day per user
Built at Pearl Hacks