DynamoDB Single-Table Pattern: Content Management (CMS)
Content management systems have two interesting DynamoDB challenges: many-to-many relationships (articles ↔ tags) and content versioning (tracking every edit, displaying the latest).
Relational databases handle both with junction tables and temporal queries. DynamoDB handles them with junction entities and composite sort keys - different mechanics, same result.
This pattern covers a headless CMS with articles, authors, tags, and a full version history. It works for blogs, documentation sites, knowledge bases, or any system where content is created, categorized, and versioned.
Access patterns
| # | Access Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get article metadata | GetItem | Title, status, author, tags |
| AP2 | Get article with latest version content | Query | Metadata + current body |
| AP3 | Get a specific version of an article | GetItem | Historical content |
| AP4 | List all versions of an article | Query | Version history |
| AP5 | List articles by author (newest first) | Query (GSI1) | Author dashboard |
| AP6 | List articles by tag | Query | Tag page |
| AP7 | List all tags on an article | Query (GSI1) | Article detail sidebar |
| AP8 | Publish a new version | TransactWriteItems | Write version + update metadata |
Eight access patterns. Three entity types. One table, one GSI (overloaded).
Entities
Three entity types:
- Article: metadata, status, author, and a denormalized tag set for display
- ArticleVersion: the full body content per version, kept separate to avoid loading bodies in list views
- TagArticle: junction entity for many-to-many articles ↔ tags, queryable in both directions via GSI

Table design
Primary key structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Article | ARTICLE#<articleId> | #METADATA | Article metadata, status, tags |
| ArticleVersion | ARTICLE#<articleId> | VERSION#<version> | Full body content per version |
| TagArticle | TAG#<tag> | ARTICLE#<articleId> | Junction entity for tag → articles |
The article metadata (title, status, author) and the article body (full content) live in separate items. Listing articles doesn’t load their full body content - a huge efficiency win for dashboards and list views. The body is only fetched when viewing a specific article. Nesting versions as a list attribute would blow past the 400KB item limit fast - it’s the same mistake covered in common single-table design mistakes.
The TagArticle junction entity handles many-to-many. To find all articles with tag “dynamodb”, Query PK = TAG#dynamodb. To find all tags on an article, Query GSI1 with PK = ARTICLE#<id>. The same junction entity serves both directions through the inverted GSI.
Tags are also denormalized on the Article record. The tags attribute (a string set) on the article metadata is for display purposes - showing tags on an article card without joining to the junction entity. The TagArticle junction is the authoritative source for tag queries.

Versioning
Publishing a new version is a transaction:
async function publishVersion(articleId: string, body: string, authorId: string) {
const article = await ArticleEntity.get({ articleId }).go();
const newVersion = article.data.currentVersion + 1;
await client.transactWrite({
TransactItems: [
{ Put: { /* new ArticleVersion item with version = newVersion */ } },
{ Update: {
/* update Article #METADATA: currentVersion = newVersion, updatedAt = now */
}},
],
});
}
To read the latest version: Query the article partition with SK begins_with VERSION# and ScanIndexForward: false, Limit: 1. Or use the currentVersion field on the metadata to construct a direct GetItem: SK = VERSION#<currentVersion>.
Sample data
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
ARTICLE#01HW... | #METADATA | AUTHOR#u_01 | ARTICLE#01HW... | { title: "DynamoDB Patterns", status: "published", tags: ["dynamodb", "aws"], currentVersion: 3 } |
ARTICLE#01HW... | VERSION#1 | - | - | { body: "First draft...", wordCount: 450, createdBy: "u_01" } |
ARTICLE#01HW... | VERSION#2 | - | - | { body: "Revised draft...", wordCount: 620, createdBy: "u_01" } |
ARTICLE#01HW... | VERSION#3 | - | - | { body: "Published version...", wordCount: 780, createdBy: "u_01" } |
TAG#dynamodb | ARTICLE#01HW... | ARTICLE#01HW... | TAG#dynamodb | { taggedAt: "2026-05-04T..." } |
TAG#aws | ARTICLE#01HW... | ARTICLE#01HW... | TAG#aws | { taggedAt: "2026-05-04T..." } |
ElectroDB entity definitions
export const ArticleEntity = new Entity({
model: { entity: "article", version: "1", service: "cms" },
attributes: {
articleId: { type: "string", required: true }, // ULID
title: { type: "string", required: true },
slug: { type: "string", required: true },
excerpt: { type: "string" },
status: { type: "string", required: true, default: "draft",
enum: ["draft", "published", "archived"] },
authorId: { type: "string", required: true },
publishedAt: { type: "string" },
currentVersion: { type: "number", required: true, default: 1 },
tags: { type: "set", items: "string" }, // denormalized for display
createdAt: {
type: "string", required: true,
default: () => new Date().toISOString(), readOnly: true,
},
updatedAt: {
type: "string", required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(), watch: "*",
},
},
indexes: {
primary: {
pk: { field: "pk", composite: ["articleId"], template: "ARTICLE#${articleId}" },
sk: { field: "sk", composite: [], template: "#METADATA" },
},
byAuthor: {
index: "GSI1",
pk: { field: "gsi1pk", composite: ["authorId"], template: "AUTHOR#${authorId}" },
sk: { field: "gsi1sk", composite: ["articleId"], template: "ARTICLE#${articleId}" },
},
},
}, { client, table }); Why this design
Separating metadata from content is the highest-impact decision here. Article list pages load fast because they never read the full body. Version history is cheap because each version is its own item.
The junction entity is the canonical DynamoDB solution for many-to-many relationships. More verbose than a SQL join table, but it gives you O(1) lookups in both directions via the inverted GSI.
The tags set on the article metadata is a read optimization - displaying tags on an article card is one read instead of a GSI query. The tradeoff: when you add or remove a tag, you update both the junction entity and the article’s tag set. A TransactWriteItems call handles this atomically.
Version numbers are sequential integers, not ULIDs. Unlike other entities where ULIDs provide both ordering and uniqueness, version numbers are strictly ordered (version 3 is always after version 2) and scoped to a single article. Sequential integers are simpler and more readable here.
Design this visually → coming soon
The junction entity pattern for many-to-many relationships is one of the trickiest things to explain in text. On a canvas - where you can see the TagArticle item sitting between the TAG and ARTICLE partitions, with GSI arrows going both directions - it’s obvious. That’s what I’m building at singletable.dev.
Pattern #7 of 10 in the SingleTable pattern library. The junction entity pattern for many-to-many relationships appears in several others - the Social Media Feed uses it for followers. I’m building singletable.dev to make these relationships visible on a canvas.