Skip to content

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 PatternOperationNotes
AP1Get article metadataGetItemTitle, status, author, tags
AP2Get article with latest version contentQueryMetadata + current body
AP3Get a specific version of an articleGetItemHistorical content
AP4List all versions of an articleQueryVersion history
AP5List articles by author (newest first)Query (GSI1)Author dashboard
AP6List articles by tagQueryTag page
AP7List all tags on an articleQuery (GSI1)Article detail sidebar
AP8Publish a new versionTransactWriteItemsWrite 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
Content Management entity diagram showing Article, ArticleVersion, TagArticle relationships
3 entities · 1 GSI (overloaded) · metadata separate from body, tags as junction entity

Table design

Primary key structure

EntityPKSKPurpose
ArticleARTICLE#<articleId>#METADATAArticle metadata, status, tags
ArticleVersionARTICLE#<articleId>VERSION#<version>Full body content per version
TagArticleTAG#<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.

Content Management DynamoDB schema: Entity, PK, SK, GSI columns for Article, ArticleVersion, TagArticle
PK/SK structure for Article, ArticleVersion, and TagArticle — one overloaded GSI serves both tag directions

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

pkskgsi1pkgsi1skEntity Data
ARTICLE#01HW...#METADATAAUTHOR#u_01ARTICLE#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#dynamodbARTICLE#01HW...ARTICLE#01HW...TAG#dynamodb{ taggedAt: "2026-05-04T..." }
TAG#awsARTICLE#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.

Join the waitlist →


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.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.