Skip to main content

Blog System

A complete example of building a blog system with users, posts, comments, and tags using Dynatable.

Schema Design

GSI key templates live on each model's index: field, not inside attributes:.

export const BlogSchema = {
format: 'dynatable:1.0.0',
version: '1.0.0',

indexes: {
primary: {
hash: 'PK',
sort: 'SK',
},
gsi1: {
hash: 'GSI1PK',
sort: 'GSI1SK',
},
},

models: {
User: {
key: {
PK: { type: String, value: 'USER#${username}' },
SK: { type: String, value: 'USER#${username}' },
},
attributes: {
username: { type: String, required: true },
name: { type: String, required: true },
email: { type: String, required: true },
bio: { type: String },
avatar: { type: String },
followerCount: { type: Number, default: 0 },
followingCount: { type: Number, default: 0 },
},
},

Post: {
key: {
PK: { type: String, value: 'USER#${username}' },
SK: { type: String, value: 'POST#${postId}' },
},
index: {
// GSI for querying all posts by status
GSI1PK: { type: String, value: 'POST' },
GSI1SK: { type: String, value: 'STATUS#${published}#${postId}' },
},
attributes: {
username: { type: String, required: true },
postId: { type: String, generate: 'ulid' },
title: { type: String, required: true },
content: { type: String, required: true },
published: { type: Boolean, default: false },
views: { type: Number, default: 0 },
likes: { type: Number, default: 0 },
},
},

Comment: {
key: {
PK: { type: String, value: 'POST#${postId}' },
SK: { type: String, value: 'COMMENT#${commentId}' },
},
index: {
// GSI for querying comments by user
GSI1PK: { type: String, value: 'USER#${username}' },
GSI1SK: { type: String, value: 'COMMENT#${commentId}' },
},
attributes: {
postId: { type: String, required: true },
commentId: { type: String, generate: 'ulid' },
username: { type: String, required: true },
content: { type: String, required: true },
likes: { type: Number, default: 0 },
},
},

Tag: {
key: {
PK: { type: String, value: 'POST#${postId}' },
SK: { type: String, value: 'TAG#${tag}' },
},
index: {
// GSI for reverse lookup (tag → posts)
GSI1PK: { type: String, value: 'TAG#${tag}' },
GSI1SK: { type: String, value: 'POST#${postId}' },
},
attributes: {
postId: { type: String, required: true },
tag: { type: String, required: true },
},
},
},

params: {
timestamps: true,
},
} as const;

Table Setup

import { Table } from '@ftschopp/dynatable-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { BlogSchema } from './schema';

export const table = new Table({
name: 'BlogTable',
client: new DynamoDBClient({ region: 'us-east-1' }),
schema: BlogSchema,
});

User Operations

Create User

async function createUser(username: string, name: string, email: string) {
return await table.entities.User.put({
username,
name,
email,
bio: '',
followerCount: 0,
followingCount: 0,
})
.ifNotExists()
.execute();
}

// Usage
await createUser('alice', 'Alice Smith', 'alice@example.com');

Get User Profile

async function getUserProfile(username: string) {
return await table.entities.User.get({
username,
}).execute();
}

// Usage
const user = await getUserProfile('alice');
console.log(user.name, user.bio);

Update User Profile

async function updateUserProfile(
username: string,
updates: { name?: string; bio?: string; avatar?: string }
) {
let query = table.entities.User.update({ username });

if (updates.name) query = query.set('name', updates.name);
if (updates.bio) query = query.set('bio', updates.bio);
if (updates.avatar) query = query.set('avatar', updates.avatar);

return await query.returning('ALL_NEW').execute();
}

// Usage
await updateUserProfile('alice', {
bio: 'Senior software engineer and tech blogger',
avatar: 'https://example.com/avatar.jpg',
});

Post Operations

Create Post

async function createPost(
username: string,
title: string,
content: string,
published: boolean = false
) {
return await table.entities.Post.put({
username,
title,
content,
published,
}).execute();
}

// Usage
const post = await createPost(
'alice',
'Getting Started with DynamoDB',
'DynamoDB is a powerful NoSQL database...',
true
);

console.log(post.postId); // Auto-generated ULID

Get Post

async function getPost(username: string, postId: string) {
return await table.entities.Post.get({
username,
postId,
}).execute();
}

Get All Posts by User

async function getUserPosts(username: string, limit: number = 20) {
return await table.entities.Post.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(limit)
.scanIndexForward(false) // Newest first
.execute();
}

// Usage
const posts = await getUserPosts('alice', 10);
posts.forEach((post) => {
console.log(post.title, post.createdAt);
});

Get All Published Posts

async function getPublishedPosts(limit: number = 50) {
return await table.entities.Post.query()
.where((attr, op) =>
op.and(op.eq(attr.GSI1PK, 'POST'), op.beginsWith(attr.GSI1SK, 'STATUS#true'))
)
.useIndex('gsi1')
.limit(limit)
.scanIndexForward(false)
.execute();
}

// Usage
const publishedPosts = await getPublishedPosts(20);

Update Post

async function updatePost(
username: string,
postId: string,
updates: { title?: string; content?: string; published?: boolean }
) {
let query = table.entities.Post.update({ username, postId });

if (updates.title) query = query.set('title', updates.title);
if (updates.content) query = query.set('content', updates.content);
if (updates.published !== undefined) {
query = query.set('published', updates.published);
}

return await query.returning('ALL_NEW').execute();
}

Increment Post Views

async function incrementPostViews(username: string, postId: string) {
return await table.entities.Post.update({
username,
postId,
})
.add('views', 1)
.execute();
}

Like Post

async function likePost(username: string, postId: string) {
return await table.entities.Post.update({
username,
postId,
})
.add('likes', 1)
.execute();
}

Delete Post

async function deletePost(username: string, postId: string) {
return await table.entities.Post.delete({
username,
postId,
}).execute();

// Note: comments and tags live under different partition keys.
// To clean them up, query each related model and delete the items
// in a transaction or batch as appropriate.
}

Comment Operations

Add Comment

async function addComment(postId: string, username: string, content: string) {
return await table.entities.Comment.put({
postId,
username,
content,
}).execute();
}

// Usage
const comment = await addComment('post123', 'bob', 'Great article! Very informative.');

Get Post Comments

async function getPostComments(postId: string) {
return await table.entities.Comment.query()
.where((attr, op) => op.eq(attr.postId, postId))
.scanIndexForward(true) // Oldest first
.execute();
}

// Usage
const comments = await getPostComments('post123');
comments.forEach((comment) => {
console.log(`${comment.username}: ${comment.content}`);
});

Get User Comments

async function getUserComments(username: string, limit: number = 50) {
return await table.entities.Comment.query()
.where((attr, op) => op.eq(attr.username, username))
.useIndex('gsi1')
.limit(limit)
.scanIndexForward(false)
.execute();
}

Like Comment

async function likeComment(postId: string, commentId: string) {
return await table.entities.Comment.update({
postId,
commentId,
})
.add('likes', 1)
.execute();
}

Tag Operations

Add Tags to Post

async function addTagsToPost(postId: string, tags: string[]) {
const tagItems = tags.map((tag) => ({
postId,
tag: tag.toLowerCase(),
}));

await table.entities.Tag.batchWrite(tagItems).execute();
}

// Usage
await addTagsToPost('post123', ['typescript', 'dynamodb', 'tutorial']);

Get Post Tags

async function getPostTags(postId: string) {
const tags = await table.entities.Tag.query()
.where((attr, op) => op.eq(attr.postId, postId))
.execute();

return tags.map((t) => t.tag);
}

// Usage
const tags = await getPostTags('post123');
console.log(tags); // ['typescript', 'dynamodb', 'tutorial']

Get Posts by Tag

async function getPostsByTag(tag: string, limit: number = 20) {
return await table.entities.Tag.query()
.where((attr, op) => op.eq(attr.tag, tag.toLowerCase()))
.useIndex('gsi1')
.limit(limit)
.execute();
}

// Usage
const typescriptPosts = await getPostsByTag('typescript', 10);

Complete Workflows

Publish Post with Tags

async function publishPostWithTags(
username: string,
title: string,
content: string,
tags: string[]
) {
// Create post
const post = await table.entities.Post.put({
username,
title,
content,
published: true,
}).execute();

// Add tags
await addTagsToPost(post.postId, tags);

// Increment user post count
await table.entities.User.update({ username }).add('postCount', 1).execute();

return post;
}

// Usage
const post = await publishPostWithTags(
'alice',
'DynamoDB Best Practices',
'Here are some best practices...',
['dynamodb', 'aws', 'database']
);

Get Post with Comments

async function getPostWithComments(username: string, postId: string) {
const [post, comments, tags] = await Promise.all([
getPost(username, postId),
getPostComments(postId),
getPostTags(postId),
]);

return {
...post,
comments,
tags,
};
}

// Usage
const fullPost = await getPostWithComments('alice', 'post123');
console.log(fullPost.title);
console.log(`${fullPost.comments.length} comments`);
console.log(`Tags: ${fullPost.tags.join(', ')}`);

Get User Feed

async function getUserFeed(username: string, page: number = 1, pageSize: number = 20) {
const result = await table.entities.Post.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(pageSize)
.scanIndexForward(false)
.executeWithPagination();

return {
posts: result.items,
hasMore: !!result.lastEvaluatedKey,
nextPageToken: result.lastEvaluatedKey,
};
}

Search Posts by Keyword (Simple)

async function searchPosts(keyword: string) {
// Note: This uses scan - not efficient for large tables
// In production, use Elasticsearch or similar
const allPosts = await table.entities.Post.scan()
.where((attr, op) =>
op.and(
op.eq(attr.published, true),
op.or(op.contains(attr.title, keyword), op.contains(attr.content, keyword))
)
)
.execute();

return allPosts;
}

Pagination Example

async function getPaginatedPosts(limit: number = 20, lastKey?: any) {
let query = table.entities.Post.query()
.where((attr, op) =>
op.and(op.eq(attr.GSI1PK, 'POST'), op.beginsWith(attr.GSI1SK, 'STATUS#true'))
)
.useIndex('gsi1')
.limit(limit)
.scanIndexForward(false);

if (lastKey) {
query = query.startFrom(lastKey);
}

return await query.executeWithPagination();
}

// Usage - get first page
const page1 = await getPaginatedPosts(20);
console.log(page1.items);

// Get next page
if (page1.lastEvaluatedKey) {
const page2 = await getPaginatedPosts(20, page1.lastEvaluatedKey);
console.log(page2.items);
}

Error Handling

async function createPostSafely(username: string, title: string, content: string) {
try {
const post = await table.entities.Post.put({
username,
title,
content,
}).execute();

return { success: true, post };
} catch (error) {
console.error('Failed to create post:', error);
return { success: false, error: error.message };
}
}

Testing

import { describe, it, expect, beforeAll } from 'vitest';

describe('Blog System', () => {
beforeAll(async () => {
// Setup test data
await createUser('testuser', 'Test User', 'test@example.com');
});

it('should create a post', async () => {
const post = await createPost('testuser', 'Test Post', 'Test content', true);

expect(post.username).toBe('testuser');
expect(post.title).toBe('Test Post');
expect(post.postId).toBeDefined();
});

it('should get user posts', async () => {
const posts = await getUserPosts('testuser');
expect(posts.length).toBeGreaterThan(0);
});

it('should add comment to post', async () => {
const posts = await getUserPosts('testuser');
const post = posts[0];

const comment = await addComment(post.postId, 'testuser', 'Test comment');

expect(comment.content).toBe('Test comment');
});
});

Next Steps

This example demonstrates:

  • ✅ Single-table design
  • ✅ One-to-many relationships (User → Posts, Post → Comments)
  • ✅ Many-to-many relationships (Posts ↔ Tags)
  • ✅ GSI usage for alternative access patterns
  • ✅ Pagination
  • ✅ Atomic operations
  • ✅ Type safety throughout

For more examples, see: