{"slug": "modeling-a-creator-saas-in-a-single-dynamodb-table", "title": "Modeling a Creator SaaS in a Single DynamoDB Table", "summary": "A developer built Truss, a B2C SaaS for content creators, using a single DynamoDB table to model six entities including users, videos, clips, streams, analytics, and platform tokens. The single-table design was chosen over a relational database because the dominant read pattern is partition-scoped, DynamoDB's HTTP API fits serverless compute better than connection-pooled databases, and on-demand capacity scales to zero for a hackathon-born product. The schema uses a partition key of CREATOR#<userId> and sort keys with entity prefixes, enabling queries via begins_with without secondary indexes, though hot partitions and lack of ad-hoc querying are acknowledged trade-offs.", "body_md": "*Submitted to the AWS H0 Hackathon — Vercel v0 + AWS Databases track. #H0Hackathon*\n\nTruss is a B2C SaaS for content creators. You upload a video or connect a live stream, and Truss uses Gemini to extract the highest-engagement moments, scores each for virality, and publishes 9:16 vertical clips across YouTube, TikTok, Twitch, and Discord.\n\nThe data model behind this sounds deceptively simple: users, videos, clips, streams, analytics, platform tokens. Six entities. But the *access patterns* are what matter — and they pushed me toward a single-table DynamoDB design over a relational database in ways I didn't fully anticipate when I started.\n\nThe first question I get is always: why not just use a relational database?\n\nThree reasons shaped this decision.\n\n**1. The dominant read pattern is partition-scoped.** Almost every page in this app asks the same question: *\"Give me everything for user X, filtered by entity type.\"* Dashboard needs recent assets and analytics. Clips page needs clips. Streams page needs streams. In SQL, that's joins or multiple round-trips. In DynamoDB with a single-table design, it's one `Query`\n\ncall per page — no joins, no N+1 problems.\n\n**2. Serverless + connection pools don't mix well.** Vercel functions are stateless and short-lived. Every cold start to a Postgres database burns 30–80ms negotiating a connection before the first query runs. DynamoDB's HTTP API is connectionless by design — it's a natural fit for serverless compute.\n\n**3. On-demand capacity.** At launch, I have no idea how many creators will sign up. DynamoDB on-demand scales to zero (no idle cost) and scales up with zero capacity planning. For a hackathon-born product, that's the right default.\n\nEverything lives in one DynamoDB table. Partition key: `PK`\n\n(String). Sort key: `SK`\n\n(String).\n\n```\nPK                        SK                              Entity\n─────────────────────     ──────────────────────────      ──────────────────\nCREATOR#<userId>          METADATA                        Creator profile\nCREATOR#<userId>          ASSET#<videoId>                 Uploaded video\nCREATOR#<userId>          ASSET#<videoId>#CHAPTERS        AI-extracted chapters\nCREATOR#<userId>          CLIP#<clipId>                   Extracted clip\nCREATOR#<userId>          STREAM#<streamId>               Live stream record\nCREATOR#<userId>          LIVE_CHAT_SPIKE#<timestamp>     Chat engagement spike\nCREATOR#<userId>          ANALYTICS#DAILY#<date>          Daily metrics\nCREATOR#<userId>          PLATFORM_TOKEN#<platform>       OAuth token\nMAGIC#<token>             VERIFY                          Magic link token (TTL)\n```\n\nEvery entity for a given user shares the same partition key. Queries use `begins_with(SK, prefix)`\n\nto retrieve a specific entity type:\n\n```\n// All clips for a user\nawait docClient.send(new QueryCommand({\n  TableName: TABLE,\n  KeyConditionExpression: \"PK = :pk AND begins_with(SK, :sk)\",\n  ExpressionAttributeValues: {\n    \":pk\": `CREATOR#${userId}`,\n    \":sk\": \"CLIP#\",\n  },\n}));\n```\n\nNo GSIs. No secondary indexes. All access patterns are served by this one schema.\n\nSingle-table design has costs worth naming honestly.\n\n**Hot partitions are a real risk at scale.** If one creator has 50,000 clips, their partition takes all the heat. DynamoDB on-demand handles burst well, but you can't fully escape this. For V2, I'd add a shard suffix to `PK`\n\nfor high-volume entities — `CREATOR#<userId>#CLIPS#<shard>`\n\n.\n\n**You lose ad-hoc querying.** There's no `SELECT * FROM clips WHERE viralityScore > 80`\n\n. Any access pattern you didn't model upfront requires a scan or a GSI. I've been careful to design the app's UI around the access patterns I built, not the other way around. That discipline is uncomfortable at first.\n\n**Magic link tokens use a different partition scheme** (`MAGIC#<token>`\n\n) because they need to be looked up by token, not by user. Mixing these in the same table is fine — it's by design in single-table modeling — but it requires deliberate thought about what \"partition\" means for each entity type.\n\nThe frontend is Next.js App Router on Vercel. A few things worth noting for other developers.\n\n**Route protection runs at the edge.** A `proxy.ts`\n\nmiddleware checks the NextAuth session cookie (`authjs.session-token`\n\n) before any server component renders. Unauthenticated users get redirected to `/login`\n\nat the CDN layer — no compute wasted.\n\n**Video uploads bypass Vercel entirely.** The browser requests a presigned S3 PUT URL from an API route, then uploads directly to S3. Vercel never sees a raw video byte. This avoids Vercel's response size limits and keeps egress costs at zero.\n\n**Credential federation, not static keys.** In production, there are no `AWS_ACCESS_KEY_ID`\n\nenvironment variables. Vercel injects an OIDC token; the app calls `sts:AssumeRoleWithWebIdentity`\n\nto get short-lived credentials, cached in-process with a 60-second expiry buffer. This took an afternoon to set up and eliminated an entire category of credential-leak risk.\n\nThe analysis pipeline is currently synchronous — it blocks the HTTP request while Gemini processes the video. For anything longer than a short clip, that's a race against Vercel's function timeout. The next version will push analysis to a background queue (Vercel Queues) with status polling.\n\nDynamoDB queries also have no pagination yet. For a creator with thousands of clips, every page load fetches the full partition. Adding `Limit`\n\n+ `ExclusiveStartKey`\n\nis straightforward — it just wasn't the bottleneck at hackathon scale.\n\nTruss is live at [the-truss-app.vercel.app](https://the-truss-app.vercel.app). The stack — Next.js on Vercel, single-table DynamoDB, S3 for object storage, Vercel AI SDK for Gemini — held up well under the build pressure of a hackathon.\n\nIf you're a developer considering this stack for a B2C SaaS: the single-table DynamoDB pattern has a real learning curve, but once your access patterns click into the schema, the operational simplicity pays for the upfront modeling cost many times over.\n\n**#H0Hackathon**", "url": "https://wpnews.pro/news/modeling-a-creator-saas-in-a-single-dynamodb-table", "canonical_source": "https://dev.to/jwambui/exploring-single-h38", "published_at": "2026-06-29 21:06:43+00:00", "updated_at": "2026-06-29 21:18:49.246042+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "generative-ai", "ai-infrastructure", "machine-learning"], "entities": ["Truss", "DynamoDB", "Gemini", "Vercel", "AWS", "YouTube", "TikTok", "Twitch"], "alternates": {"html": "https://wpnews.pro/news/modeling-a-creator-saas-in-a-single-dynamodb-table", "markdown": "https://wpnews.pro/news/modeling-a-creator-saas-in-a-single-dynamodb-table.md", "text": "https://wpnews.pro/news/modeling-a-creator-saas-in-a-single-dynamodb-table.txt", "jsonld": "https://wpnews.pro/news/modeling-a-creator-saas-in-a-single-dynamodb-table.jsonld"}}