an ai social-media content studio. pick a platform, set tone, audience, and goal, hit generate, and watch three caption variants stream in token-by-token. save the ones you like to a library and "more like this" from there.
runs fully locally: postgres in docker, email/password auth, no cloud accounts.
- generates platform-tuned captions with per-platform prompt rules
- streams three variants live per request
- counts one quota unit per generate action, not per variant
- "more like this" reuses the original tone, audience, and goal
- framework - tanstack start (vite, file-based routes, server functions, server routes)
- data - postgres 17 + drizzle orm over a single shared
pgpool - auth - better auth, email/password plus optional google oauth
- ai - nebius ai studio (qwen3) through the openai sdk, optional
routes live in src/routes; /api/* are server routes that return plain web Responses. all business logic sits in src/server/ as framework-agnostic plain functions, so the route handlers are thin shells. protected /api/* routes call requireApiUser() and return the same 401 envelope when there's no session, while public routes like health and auth stay explicit.
docker compose up -d # local postgres
cp .env.example .env # set BETTER_AUTH_SECRET (openssl rand -base64 32)
pnpm install
pnpm db:push # drizzle schema -> postgres
pnpm db:seed # demo user + sample content
pnpm dev # vite on :3000only DATABASE_URL and BETTER_AUTH_SECRET are required. set NEBIUS_API_KEY or GROQ_API_KEY to enable generation; with neither, endpoints return a labeled AI_UNAVAILABLE envelope instead of a 500. pnpm db:seed prints demo credentials to the console.
-
a full framework cutover done in stages, not a rewrite (
src/routes,src/server). the app moved off next.js app router onto tanstack start while staying shippable at every step: database first, then auth, then a framework-agnostic service layer, then the framework swap, then tooling. business logic was extracted intosrc/server/*plain functions before the move so the route handlers became disposable. -
streaming with reasoning-block filtering (
src/server/ai.ts→createChatStream,src/routes/api/generate-caption.ts). qwen emits<think>blocks; naive token passthrough leaks them. the stream buffers just until it knows whether it's inside a think block, drops it, then streams the rest straight through - first real token stays sub-second. -
three variants, one quota unit (
src/routes/api/generate-caption.ts,src/server/usage.ts). one user action fires three parallel streamed calls with temperature jitter; only the first carriestrackUsage, so the quota check and increment run once. quotas are computed from actualcontent/user_activityrows for the day, not a mutable counter. -
a typed service layer with a standard auth/error envelope (
src/server/api-auth.ts,src/server/errors.ts). protected handlers callrequireApiUser()before touching user-owned data, and errors return{ error: { code, message } }with codes likeQUOTA_EXCEEDED,UNAUTHORIZED,AI_UNAVAILABLE,VALIDATION_ERROR. the client readsmessagestraight into a toast. -
content library on composable drizzle queries (
src/server/content.ts,src/routes/api/content/). platform/status/type/date filters andilikesearch compose a singlewhere, paginate with limit/offset, and "more like this" rehydrates the original generation inputs. -
better auth on drizzle with additional fields (
src/lib/auth.ts,src/lib/get-session.ts). email/password and conditional google, with app fields (subscription,isActive,lastLoginAt) declared as better-auth additional fields so the session carries them directly - no email-then-lookup round trip. the seed creates the demo user throughauth.api.signUpEmailso the scrypt hash is real. -
db-backed rate limiting (
src/server/rate-limit.ts). a postgres sliding window via an atomic upsert, so it survives a restart and doesn't need redis.