A self-maintaining post series in Payload
I wanted "Part 2 of 4" chrome on my blog posts, and I didn't want to maintain it.
The naive version is easy to picture and miserable to live with: add a seriesName and a partNumber field to every post, type them by hand, and hard-code the "next up" links in the post body. Then you add a part in the middle, and now you're renumbering four posts and rewriting every prev/next link by hand. Reorder two of them and you do it again. The data that says "these posts form a series, in this order" is smeared across every member, so there's no one place to change it and no way to keep it honest.
So I built it the other way around. One collection owns the whole idea of a series, and every post derives its banner and navigation from that. Add a part, drag it into place, and every member page updates itself. Here's how it fits together, including the one bug that taught me the most.
One collection is the source of truth
The whole feature rests on a single decision: membership and reading order live in exactly one place, and it isn't on the posts.
export const Series: CollectionConfig = {
slug: 'series',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug'],
},
fields: [
{ name: 'title', type: 'text', required: true },
slugField(),
{
name: 'description',
type: 'textarea',
admin: {
description: 'Optional intro shown above the list of posts in the series.',
},
},
{
name: 'posts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
required: true,
admin: {
description: 'The posts in this series, in reading order. Drag to reorder.',
},
},
],
}That posts relationship is the entire trick. hasMany: true makes it a list, and Payload renders a hasMany relationship as a drag-to-reorder list in the admin, so the order of the array is the reading order. There is no partNumber field anywhere, because a part number is just an index. There's no seriesName on the post either, because membership is the presence of the post's id in this list.
Two properties fall out of this for free. There's no way to get the numbering wrong, because nobody types a number. And there's no such thing as an orphaned reference, because "part 3" isn't a string that has to match. It's a real relationship to a real post.
The chrome is derived, never placed
If a series owns membership and order, a post has to ask which series it belongs to. That's a reverse query: the relationship points from series to posts, and we look it up backwards.
export async function getSeriesForPost(postId: number): Promise<Series | null> {
const payload = await payloadClient();
const result = await payload.find({
collection: 'series',
where: { posts: { equals: postId } },
limit: 1,
depth: 1,
});
// ...
}depth: 1 is doing real work here. It tells Payload to populate the posts relationship into full Post documents instead of leaving them as bare ids, so the banner and nav get every sibling's title and slug from this one query, with no N+1 follow-ups to render the list.
The post page calls this once and hands the result to two components:
const series = await getSeriesForPost(post.id);
// ...
{series && <SeriesBanner series={series} currentPostId={post.id} />}
{/* ...the post content... */}
{series && <SeriesNav series={series} currentPostId={post.id} />}Both components find the current post in the ordered list and compute everything positionally. The banner is one line:
export function SeriesBanner({ series, currentPostId }: Props) {
const posts = orderedPosts(series);
const index = posts.findIndex((post) => post.id === currentPostId);
if (index < 0 || posts.length < 2) return null;
return (
<p className={styles.banner}>
Part {index + 1} of {posts.length} in the series <strong>{series.title}</strong>.{' '}
<Link href="#series-nav">Full series ↓</Link>
</p>
);
}index + 1 is the "Part N," posts.length is the "of M," and prev/next in the nav are just posts[index - 1] and posts[index + 1]. Nothing is stored; it's all read off the array's shape. The posts.length < 2 guard means a series with a single post renders no chrome at all. A series of one isn't a series yet.
One small guard is worth calling out. The ordered list keeps only entries that populated to full documents:
function orderedPosts(series: Series): Post[] {
return (series.posts ?? []).filter(
(post): post is Post => typeof post === 'object' && post !== null,
);
}A relationship entry that's still a bare id (an unpopulated ref, or a since-deleted post) gets skipped instead of rendering as a broken row. The type predicate (post is Post) also narrows the array from (number | Post)[] down to Post[], so the rest of the component works with real documents and TypeScript agrees.
The payoff: add a post to the series, or drag it up two spots, and you touch exactly one record. Every member page recomputes its own "Part N of M" and its own prev/next from the new array. The author never places the chrome, so the author can never place it wrong.
Revalidation is the actually-hard part
A derived UI on statically generated pages is where this gets interesting. My post pages are prerendered. When you edit a series, the series record isn't what the reader sees. The reader sees member post pages, each of which baked in a snapshot of its siblings' titles and links. Change the order, and every one of those pages is now stale, even though you never touched a post.
So editing a series has to revalidate every affected member. The wrinkle is which members. If you remove a post from a series, that post needs to drop its banner, so it's affected too, even though it's no longer in the list. The fix is to revalidate the union of the new membership and the previous membership:
const revalidateSeriesMembers: CollectionAfterChangeHook = async ({ doc, previousDoc, req }) => {
try {
await revalidatePostPages(req.payload, [
...seriesPostIds(doc?.posts),
...seriesPostIds(previousDoc?.posts),
]);
} catch (error) {
console.error('revalidateSeriesMembers: failed to revalidate', error);
}
return doc;
};doc.posts is the new set, previousDoc.posts is the old set. Concatenating them covers additions (in the new set), removals (in the old set), and everyone who stayed (in both). Anyone who stayed shows up twice, which is fine, because the dedup happens downstream:
export async function revalidatePostPages(payload: Payload, ids: number[]): Promise<void> {
const unique = [...new Set(ids)];
if (unique.length === 0) return;
const result = await payload.find({
collection: 'posts',
where: { id: { in: unique } },
depth: 0,
limit: unique.length,
pagination: false,
});
for (const post of result.docs) {
if (post.slug) revalidatePath(`/blog/${post.slug}`);
}
}The new Set(ids) collapses the duplicates so each page is revalidated once, and we do a single batched find (id: { in: unique }) to turn ids into slugs rather than querying per post.
The same problem runs the other direction. Editing a post (retitling it, changing its slug) changes how that post appears in its siblings' banners and nav lists. So the post's own afterChange hook reaches sideways to refresh everyone in its series:
export async function revalidateSeriesSiblings(payload: Payload, postId: number): Promise<void> {
const result = await payload.find({
collection: 'series',
where: { posts: { equals: postId } },
depth: 0,
limit: 50,
pagination: false,
});
const ids = result.docs.flatMap((series) => seriesPostIds(series.posts));
await revalidatePostPages(payload, ids);
}Deriving the UI moves the maintenance cost from authoring to cache invalidation. That's a good trade: cache invalidation is a problem you solve once in a hook, while authoring is a tax you pay on every edit forever. But you do have to actually solve it, and the edges are where it bites.
The delete-and-cascade gotcha
This is the bug I'm most glad I hit, because the fix is one word and the reasoning behind it is the whole point of the feature.
When you delete a post that's in a series, its siblings' pages need to refresh so they stop listing the deleted post. My first instinct was the obvious one: do it in afterDelete. The post is gone, react to it, clean up.
It doesn't work, and the reason is in the database schema, not the application code. The series_rels join table that backs the relationship has ON DELETE CASCADE on posts. So the instant a post is deleted, Postgres removes its membership rows too, automatically, as part of the same delete. By the time afterDelete runs, the link between the post and its series is already gone. The reverse query that finds the siblings comes back empty, so nothing gets revalidated, and every other member page keeps happily listing a post that no longer exists.
(Credit where due: Bugbot flagged this on the PR. It's the kind of thing that passes every manual test. Deletes work, the post disappears, and it only surfaces later as a stale link. I'm glad a machine was reading too.)
The fix is to move the work earlier, to beforeDelete, while the relationship still exists:
const revalidateSeriesBeforeDelete: CollectionBeforeDeleteHook = async ({ req, id }) => {
try {
await revalidateSeriesSiblings(req.payload, Number(id));
} catch (error) {
console.error('revalidateSeriesBeforeDelete: failed to revalidate', error);
}
};In beforeDelete the row hasn't been touched yet, so the reverse query still finds the siblings and marks their pages stale. They regenerate (without the deleted post) after the delete commits. The lesson generalizes past Payload: when a cascade is going to erase the data you need to react to, read it before the cascade fires, not after. afterDelete is too late by construction.
A couple of typing notes
Two small things that made this pleasant instead of a cast-fest.
First, payload.find is fully typed with no manual assertions. When you run pnpm generate:types, Payload writes a payload-types.ts and augments its own module, so payload.find({ collection: 'series' }) knows the result is a Series and .posts has the right shape. You pass a collection slug string and get typed documents back, with no as Series[] anywhere.
Second, the shape of a hasMany relationship is worth internalizing: Series['posts'] is (number | Post)[]. Each entry is either a bare id (when you queried at depth: 0) or a populated document (at depth: 1). That union is exactly why the helpers keep checking typeof post === 'number':
export function seriesPostIds(posts: Series['posts'] | null | undefined): number[] {
if (!posts) return [];
return posts.map((post): number => (typeof post === 'number' ? post : post.id));
}One function that reads ids off a relationship no matter which depth produced it. Once you've seen the number | Post union, the codebase's habit of writing typeof x === 'number' ? x : x.id stops looking defensive and starts looking correct.
Styles stay next to the component
Last small thing, because it's a habit worth keeping. The series chrome has its own styling, and it lives in a colocated CSS Module next to the component. SeriesNav.module.css sits beside SeriesNav.tsx, not in a global stylesheet:
import styles from './SeriesNav.module.css';
// ...
<p className={styles.banner}>Nothing here is a general site style, so nothing here belongs in global.css. The class names are scoped and hashed, so .banner can't collide with anyone else's .banner, and when I delete this feature someday the styles leave with it.
What it feels like to use
The test of a self-maintaining feature is the edit you don't have to make. Adding a part to the middle of a series is three steps: open the series, drag the new post into position, save. Every existing member's "Part N of M" shifts to account for it, every prev/next relinks, and the pages rebuild themselves. I never open the other posts.
The shape is the reusable idea, not the specifics. Put the relationship and its order in one owning record, derive every piece of presentation from it, and spend your engineering budget on cache invalidation instead of on discipline. The point of deriving everything from one record is that there's nothing left to keep in sync by hand, so there's nothing to forget.
Comments
No comments yet. Be the first to comment.