Emotial technical specification
Team: $100K MRR (Nathan Lodge, Slater Leonardo)
This is how the live site at emotial.app is built. What users can do is in the functional specification.
Overview
Emotial is a social web app, mostly text. Guests can read the feed and profiles. Users can post, reply, react with emoji, follow people, and get live notifications. Images, GIFs, and video go in MinIO. Production is four Docker containers on one machine: web, API, Postgres, and MinIO.
Architecture
Browser (Next.js)
| HTTPS + cookie
v
Web :3000 API :8080
| \
v v
Postgres MinIO (S3)
The browser calls the API directly (credentials: include). CORS allows the web origin.
The session is a JWT in an HttpOnly cookie named emotial_sess. If a client cannot store cookies, it can send Authorization: Bearer.
Live feed and notification updates are server-sent events, not WebSockets. The proposal listed Socket.io. We shipped SSE instead.
Huma builds an OpenAPI document from the Go handlers. The web app generates its TypeScript client and TanStack Query options from that document with pnpm openapi-ts.
On the API, a request goes handler to service to GORM to Postgres.
Stack
| Layer | Choice |
|---|---|
| Web | Next.js 16, React 19, TypeScript, Tailwind CSS 4 |
| Data on the client | TanStack Query, generated OpenAPI SDK, Zod |
| API | Go, Gin, Huma v2 |
| ORM | GORM |
| Database | PostgreSQL 16 (citext, pgcrypto) |
| Media | MinIO (S3 API), processed in-process with libvips |
| Auth | JWT, bcrypt, cookie |
| Live updates | SSE (/feed/stream, /notifications/stream) |
| Deploy | Docker Compose: web, api, postgres, minio |
Data model
The schema is source/schema.sql. The API does not create tables when it starts. Apply that file with psql.
Clients do not see numeric row ids. A user is named by username (lowercase, 3-16 characters, [A-Za-z0-9_]). A post is named by a 12-character public id. Internal bigserial ids stay on the server for joins and cursors.
| Table | Role |
|---|---|
users | Account and profile. Soft delete. Email and username are citext. |
retired_usernames | Usernames from closed accounts. Never reused. |
posts | Body (max 500), optional reply parent, soft delete and edited flags. |
post_media | Up to 4 images (including GIF) or 1 video, ordered by position. |
reactions | One emoji reaction per user per post. |
shares | Outbound share. Rows are not updated or removed. |
follows | Follower / followee pair. |
hashtags / post_hashtags | Tags parsed from the body. |
notifications | Reaction, reply, follow, mention. |
reports | Post or profile. An admin resolves them. |
Diagram: wiki ERD.
If someone closes an account, the email is released and the username is retired. That author's posts become tombstones: no author and no body. Mentions written before the close cannot land on a new person who picks the same name.
API
The API process serves OpenAPI. Routes are grouped like this:
| Area | Routes |
|---|---|
| Health | GET /health (200 if Postgres answers) |
| Auth | /auth/register, /auth/login, /auth/logout, /auth/me, email / password / close account |
| Users | /users/{username}, posts, replies, followers, following, friends, follow, report, /me, /suggestions |
| Posts | /feed, /posts, /posts/{id}, thread, reaction, reactions, share, report |
| Media | POST /media (upload first, then attach ids on the post) |
| Search | /search |
| Notifications | list, unread count, mark read, /notifications/stream |
| Feed live | /feed/stream |
| Admin | profiles, close account, reports |
Feed sort query: recent, trending, top, funniest, saddest, maddest, shocking.
Auth
Sign up needs display name, username, email, and a password (8 to 72 characters; bcrypt). Log in accepts email or username. Username is stored lowercase.
The JWT secret (EMOTIAL_AUTH_JWT_SECRET) has to be at least 32 characters. The cookie is HttpOnly. In production, Secure is on.
/auth/me is how the web app decides Guest vs User. If that response fails to parse, the app treats the visitor as a Guest.
Media
Upload is its own request. The composer uploads in the background. Creating a post only sends media ids.
Images (including GIF, HEIC, and AVIF) are normalized with libvips in the API process. Videos are stored as uploaded. Duration is recorded.
Objects live in the emotial bucket. Keys look like prefix/date/random so they are hard to guess. Browsers read public_url/bucket/key (path-style). Production uses a CDN origin (cdn.emotial.app).
If MinIO env is unset, the API still starts. Uploads are off.
Web app
App Router. A guest can open /, /explore, and /profile/{username}. Notifications and settings need a session.
The generated SDK is the HTTP client for documented routes. The cache holds UI shapes. Mutations update the cache first, then sync with the server.
Authored text is limited to Latin letters, everyday punctuation, and emoji. Other scripts are rejected on write so layout bombs do not get stored.
Theme and accent hue are client preferences.
Deployment
See deploy instructions.
| Service | Port | Notes |
|---|---|---|
| web | 3000 | NEXT_PUBLIC_API_BASE_URL is set when the image is built |
| api | 8080 | Env config (EMOTIAL_*). Optional config.toml |
| postgres | 5432 | Apply schema.sql once |
| minio | 9000 / 9001 | API creates the bucket when EMOTIAL_MEDIA_ENSURE_BUCKET=true |
Production is the same four containers on Slater's server, with public origins, a real JWT secret, secure cookies, and a CDN in front of MinIO.
Not in this build
These were in the original proposal and did not ship:
- Password reset email / Resend
- Google OAuth
- Socket.io
- Bot accounts / a public developer bot API (the HTTP API is for the app)
- Follower count badges