This podcast app demonstrates four key REST API design patterns from Adam Wathan's "Cruddy by Design" talk, translated from Laravel to Nuxt with Nuxt Content.
Instead of treating nested resources as actions on the parent, give them their own dedicated routes and controllers.
// Dedicated route for listing episodes
// GET /api/podcasts/full-stack-radio/episodes
export default defineEventHandler(async (event) => {
const podcastId = getRouterParam(event, 'podcastId')
const episodes = await queryCollection(event, 'examplesEpisodes')
.where('podcastSlug', podcastId)
.where('status', 'published')
.all()
return episodes
})// Dedicated route for single episode
// GET /api/podcasts/full-stack-radio/episodes/episode-1
export default defineEventHandler(async (event) => {
const episodeId = getRouterParam(event, 'episodeId')
const episode = await queryCollection(event, 'examplesEpisodes')
.where('slug', episodeId)
.first()
return episode
})When a property needs to be managed independently from its parent entity, model it as its own resource with dedicated CRUD operations.
// Independent resource for cover image updates
// PUT /api/podcasts/full-stack-radio/cover-image
export default defineEventHandler(async (event) => {
const podcastId = getRouterParam(event, 'podcastId')
const formData = await readMultipartFormData(event)
const imageFile = formData?.find(field => field.name === 'image')
// Store image and update podcast frontmatter
const imagePath = await saveImage(imageFile)
await updatePodcastFrontmatter(podcastId, { coverImage: imagePath })
return { coverImage: imagePath }
})// Progress is independent from episode content
// PUT /api/podcasts/playback-progress/episode-1
export default defineEventHandler(async (event) => {
const episodeSlug = getRouterParam(event, 'episodeSlug')
const { position, completed } = await readBody(event)
await db.insert(playbackProgress)
.values({ userId, episodeSlug, position, completed })
.onConflictDoUpdate({ set: { position, completed } })
return { success: true }
})Instead of treating many-to-many relationships as actions on one side, model the pivot table as its own resource with full CRUD operations.
// Subscriptions as a resource, not an action
// GET /api/podcasts/subscriptions
export default defineEventHandler(async (event) => {
const userId = await getUserId(event)
const subscriptions = await db
.select()
.from(subscriptionsTable)
.where(eq(subscriptionsTable.userId, userId))
return subscriptions
})// POST /api/podcasts/subscriptions
export default defineEventHandler(async (event) => {
const { podcastSlug } = await readBody(event)
const userId = await getUserId(event)
const subscription = await db.insert(subscriptionsTable)
.values({ userId, podcastSlug })
.returning()
return subscription[0]
})// DELETE /api/podcasts/subscriptions/123
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
await db.delete(subscriptionsTable)
.where(eq(subscriptionsTable.id, id))
return { success: true }
})Instead of filtering by status or using state transition actions, model different states as separate resources with their own endpoints.
// Draft episodes as a resource
// GET /api/podcasts/full-stack-radio/draft-episodes
export default defineEventHandler(async (event) => {
const podcastId = getRouterParam(event, 'podcastId')
const drafts = await queryCollection(event, 'examplesEpisodes')
.where('podcastSlug', podcastId)
.where('status', 'draft')
.all()
return drafts
})// Publishing = creating a published-episode resource
// POST /api/podcasts/published-episodes
export default defineEventHandler(async (event) => {
const { episodeSlug } = await readBody(event)
// Update status in markdown frontmatter
await updateEpisodeFrontmatter(episodeSlug, { status: 'published' })
return { success: true }
})// Unpublishing = deleting a published-episode resource
// DELETE /api/podcasts/published-episodes/episode-1
export default defineEventHandler(async (event) => {
const episodeSlug = getRouterParam(event, 'id')
// Update status in markdown frontmatter
await updateEpisodeFrontmatter(episodeSlug, { status: 'draft' })
return { success: true }
})// Playback state as a filterable resource
// GET /api/podcasts/in-progress-episodes
export default defineEventHandler(async (event) => {
const userId = await getUserId(event)
const progress = await db.select()
.from(playbackProgressTable)
.where(and(
eq(playbackProgressTable.userId, userId),
gt(playbackProgressTable.position, 0),
eq(playbackProgressTable.completed, false)
))
return progress
})