Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

LayerChoice
WebNext.js 16, React 19, TypeScript, Tailwind CSS 4
Data on the clientTanStack Query, generated OpenAPI SDK, Zod
APIGo, Gin, Huma v2
ORMGORM
DatabasePostgreSQL 16 (citext, pgcrypto)
MediaMinIO (S3 API), processed in-process with libvips
AuthJWT, bcrypt, cookie
Live updatesSSE (/feed/stream, /notifications/stream)
DeployDocker 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.

TableRole
usersAccount and profile. Soft delete. Email and username are citext.
retired_usernamesUsernames from closed accounts. Never reused.
postsBody (max 500), optional reply parent, soft delete and edited flags.
post_mediaUp to 4 images (including GIF) or 1 video, ordered by position.
reactionsOne emoji reaction per user per post.
sharesOutbound share. Rows are not updated or removed.
followsFollower / followee pair.
hashtags / post_hashtagsTags parsed from the body.
notificationsReaction, reply, follow, mention.
reportsPost 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:

AreaRoutes
HealthGET /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
MediaPOST /media (upload first, then attach ids on the post)
Search/search
Notificationslist, unread count, mark read, /notifications/stream
Feed live/feed/stream
Adminprofiles, 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.

ServicePortNotes
web3000NEXT_PUBLIC_API_BASE_URL is set when the image is built
api8080Env config (EMOTIAL_*). Optional config.toml
postgres5432Apply schema.sql once
minio9000 / 9001API 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