Introduction
In my previous post on adding series support to my blog, I discussed the motivation behind this feature—how grouping related posts into a cohesive narrative enhances content organization, improves discoverability, and fosters deeper engagement. By implementing series support, I aimed to bridge content silos, making it easier for readers to follow along while also leveraging cross-platform compatibility with DEV and Hashnode.
Now that the why is clear, it’s time to explore the how. This post is a deep dive into the technical implementation of series support in my Astro-powered blog. I’ll walk through the data modeling process, UI updates, and platform integration strategies that allow me to seamlessly structure and display series-based content—both on my site and across external platforms.
By the end of this post, you’ll see how I built a flexible, scalable series system that enhances my blogging workflow while maintaining compatibility with DEV and Hashnode’s different approaches to series. Let’s get started! 🚀
Designing a Series Data Model
The first step toward adding series support to my blog was defining how a series should be structured. To enhance my own understanding, I looked to DEV and Hashnode for inspiration. Both platforms support the creation of series for posts, but they approach the concept in dramatically different ways.
First, let's look at DEV since creating a series through the Forem API is relatively simple compared to Hashnode. To understand the DEV approach, we can look at the Forem API v1 docs for publishing an article. The key thing to notice is the series
field can be either a string
or null
. In other words, all one needs to do is pass a string value for the series when creating a draft or publishing an article. Pretty simple!
Now, let's turn our attention to Hashnode. Hashnode has a more complex approach to series creation than DEV. In contrast to DEV, Hashnode requires three additional fields for each series: slug, cover image, and description. The slug is an identifier used to create a page for the series. A series page on Hashnode displays the cover image, description, and posts in the series.
In short, DEV allows series creation by simply passing a string, while Hashnode requires additional metadata like a slug, cover image, and description. Here’s a direct comparison:
Feature | Hashnode | DEV |
---|---|---|
Slug | Yes | No |
Name | Yes | Yes |
Cover image | Yes | No |
Description | Yes | No |
Since I want to support cross-posting to both platforms, I have to support all of the Hashnode features. In terms of data, I must support the slug, name, cover image, and description. However, I do not necessarily need to have my page structure and UI mimic that of Hashnode.
Adding Series to the Content Layer
First, I had to define the structure of a series in the content layer. Here's what I came up with:
const series = defineCollection({
schema: ({ image }) => z.object({
name: z.string(),
description: z.string(),
coverImage: image(),
coverImageAlt: z.string()
}),
loader: glob({ pattern: "**/*.json", base: "./src/data/series" }),
});
I have added an additional field coverImageAlt
which represents the alt text for the cover image. In Astro v5, the slug is automatically generated from the file name using the content loader API. However, the slug in this case is represented as the ID for the collection entry. I chose JSON files for series data because they provide a structured, easily queryable format that integrates well with Astro’s content collections. Each series will be defined through a JSON file in the src/data/series
folder. So a series file src/data/series/my-new-series.json
will have a slug/ID of my-new-series
.
Here's how my approach compares to DEV and Hashnode:
Feature | Hashnode | DEV | Logarithmic Spirals |
---|---|---|---|
Slug | Yes | No | Yes |
Name | Yes | Yes | Yes |
Cover image | Yes | No | Yes |
Description | Yes | No | Yes |
Cover image alt | No | No | Yes |
To connect this to my posts, I had to add a field for a series reference to the post schema definition:
const BasePostSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImageAlt: z.string(),
tags: z.array(reference('tags')).min(1).max(5),
series: z.optional(reference('series'))
});
This BasePostSchema
is extended to create different post collections. For example, I have a collection defined for all my posts saved as markdown files and a collection for my posts imported from DEV. Here's what the markdown post collection looks like:
const PostSchema = ({ image }) => BasePostSchema.extend({
heroImage: image(),
publish: z.boolean()
});
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: "./src/data/blog" }),
schema: PostSchema
});
To start, I will only create series from markdown posts. However, I will eventually work on pulling series from DEV and Hashnode into their own content collections.
Creating New Pages
Once I had figured out what the series structure looked like, I decided to add new pages so visitors would be able to navigate easily through them. The inspiration for this approach came from Hashnode's series implementation. The reason they require more data is because they use the data to generate pages. Looking at their approach, I realized I needed the following:
- A page to show all the series on my site.
- A page for each series showing the cover image, title, description, and associated articles.
These pages provide a structured way for visitors to browse and follow series content:
-
/blog/series/
for the page showing all the series. -
/blog/series/{id}/
for the individual series pages.
Here's what the new "Series" page looks like:
And here's what an individual series page looks like:
Updating the UI
With series pages in place, the next step was ensuring smooth navigation. I made several UI updates to help visitors find and explore series content effortlessly. Here's what the new navbar looks like:
Previously, my navbar had separate links for ‘Blog’ and ‘Tags’. Now, I’ve consolidated them under a single ‘Blog’ dropdown, making room for a dedicated ‘Series’ link. This keeps the navigation clean while ensuring series content is easy to access.
Additionally, I also took cues from DEV and Hashnode to add some kind of navigation element indicating an article is part of a series. By having a link to the series associated with each article, it should be easier for visitors to find the other articles in a series. Here's an example:
In the above screenshot, we can see an open book icon with a link. The link says "Building a Better Blog with Series Support". The link directs users to the series which the article is a part of.
Platform Integration
For a while, I have been cross-posting to DEV via RSS. When new posts get published to my RSS feed, DEV can automatically import them to create drafts. However, I want to have more control over how the publishing is done. Moving to use the Forem API V1 gives me that control.
DEV
Building on the work I did to implement Hashnode cross-posting, I have added some new code to handle DEV cross-posts via the Forem API V1. To start, I had to create a new endpoint to give me JSON for DEV. The endpoint is /api/posts-for-dev.json
:
// src/pages/api/posts-for-dev.json.ts
import { POSTS } from "@content/tags-and-posts";
import type { DevDraft } from "@utils/devto";
import { getCollection, type CollectionEntry } from "astro:content";
export async function GET() {
const series = await getCollection('series');
const postData: DevDraft[] = POSTS
.filter(post => post.collection === 'blog')
.map((post: CollectionEntry<'blog'>) => {
if (!post.body) {
throw new Error(`Post with ID ${post.id} has no body`);
}
const seriesId = post.data.series?.id;
const seriesName = seriesId ? series.find(s => s.id === seriesId)?.data.name : undefined;
return {
title: post.data.title,
body_markdown: post.body,
series: seriesName,
main_image: post.data.heroImage,
description: post.data.description,
id: post.id,
tags: post.data.tags.map(tag => tag.id.replace("-","")) // Note the string replacement
};
});
return new Response(JSON.stringify(postData));
}
Something I want to call out in the above code is the string replacement tag.id.replace("-","")
. The "-"
character needs to be removed from tags because DEV only supports alphanumeric characters in their tags. I was getting the following error when trying to create a draft:
Reason for failure: {"error":"Tag \"content-strategy\" contains non-alphanumeric or prohibited unicode characters and Tag \"technical-writing\" contains non-alphanumeric or prohibited unicode characters","status":422}
Unfortunately, the DEV API was not helpful in resolving this error. In fact, the API documentation is incorrect. It states the tags
field is type string
. However, it's actually a string array. So ["webdev","api"]
is valid, but "webdev,api"
is invalid.
Anyway, moving on. The generated JSON is then read from the file system. Requests are made to the Forem API using the parsed data:
// src/integrations/cross-post.ts
const devJson = getJsonFromApiEndpoint(assets, '/api/posts-for-dev.json', routes, logger);
if (devJson) {
const { fileContent, filePath } = devJson;
const data: DevDraft[] = JSON.parse(fileContent);
const token = process.env.DEV_API_KEY;
if (token) {
await createDevDrafts(token, data, logger);
} else {
logger.error('No DEV API key configured');
}
fs.rmSync(filePath);
apiDirectory = path.dirname(filePath);
} else {
logger.error('Could not retrieve JSON for DEV cross-posts');
}
The important part of the above snippet is the call to the createDevDrafts
function. The definition for this function is as follows:
// src/utils/devto.ts
const createDevDrafts = async (apiKey: string, devDrafts: DevDraft[], logger: AstroIntegrationLogger) => {
const allUsedCanonicalUrls = new Set((await getAllArticles(apiKey))
.map((article: { canonical_url: string }) => article.canonical_url));
let draftsCreated = 0;
for (let i = 0; i < devDrafts.length; i ++) {
const devDraft = devDrafts[i];
const canonicalUrlForDraft = `${SITE}/blog/${devDraft.id}`;
const oldUrl = `${SITE}/${devDraft.id}`;
if (!(
allUsedCanonicalUrls.has(canonicalUrlForDraft)
|| allUsedCanonicalUrls.has(canonicalUrlForDraft + "/")
|| allUsedCanonicalUrls.has(oldUrl)
|| allUsedCanonicalUrls.has(oldUrl + "/")
)) {
const resultStatus = await createDraft(apiKey, devDraft, logger);
if (resultStatus === 201) {
draftsCreated += 1;
allUsedCanonicalUrls.add(canonicalUrlForDraft);
logger.info(`Draft created on DEV with slug ${devDraft.id}`);
} else if (resultStatus) {
const statusMeaning = ERROR_STATUS_CODE_TO_MEANING.get(resultStatus);
logger.error(`Upload to DEV failed for slug ${devDraft.id} with reason ${statusMeaning}`);
} else {
logger.error(`No status code received from DEV for slug ${devDraft.id}`);
}
} else {
logger.info(`Post exists on DEV with slug ${devDraft.id}`);
}
}
logger.info(`DEV drafts created = ${draftsCreated}`);
};
The important part to take note of is the if-condition which checks whether or not the current canonical URL has been used. This check prevents multiple copies of a draft from being created. The canonical URLs are created by first getting a list of
all the articles from my account:
// src/utils/devto.ts
const getAllArticles = async (apiKey: string) => {
return await (await fetch(`https://dev.to/api/articles/me/all`, {
'headers': {
'api-key': apiKey
}
})).json();
};
When an article's canonical URL has not been used, we create the draft through the Forem API by setting the published
field to false
:
// src/utils/devto.ts
const createDraft = async (apiKey: string, devDraft: DevDraft, logger: AstroIntegrationLogger) => {
try {
const response = await fetch(`https://dev.to/api/articles`, {
method: 'POST',
headers: {
'api-key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
article: {
title: devDraft.title,
body_markdown: devDraft.body_markdown,
published: false, // Creates a draft.
series: devDraft.series,
main_image: devDraft.main_image,
canonical_url: `${SITE}/blog/${devDraft.id}/`,
description: devDraft.description,
tags: devDraft.description
}
})
});
const status = response.status;
if (status !== 201) {
logger.error(`Reason for failure: ${JSON.stringify(await response.json())}`)
}
return status;
} catch (e) {
logger.error(JSON.stringify(e));
}
return undefined;
};
Hashnode
For Hashnode, I did not have to do as much work since I already had built out an integration. I simply had to update the existing code to include the creation of a series if one did not already exist:
// src/utils/hashnode.ts
// This is inside the createDraft function:
if (post.series) {
const result: SeriesResult = await grapQLClient.request(getSeriesBySlugQuery, { slug: post.series.slug });
const seriesId = result.me.publications.edges[0].node.series?.id;
if (seriesId) { // A series exists.
draftVariables.input['seriesId'] = seriesId
} else {
logger.info('No series exists with name ' + post.series.name);
const newSeriesInput: CreateSeriesInput = {
name: post.series.name,
slug: post.series.slug,
descriptionMarkdown: post.series.description,
coverImage: SITE + post.series.coverImage,
publicationId: publicationId
};
const newSeriesResult: CreateSeriesResult = await grapQLClient.request(createSeriesMutation,
{ input: newSeriesInput }); // We make a new series because one does not exist yet.
draftVariables.input['seriesId'] = newSeriesResult.createSeries.series.id;
}
}
Conceptually, this differs from DEV. With DEV, we simply pass the series name, whereas with Hashnode, we must first create a series object. Once we have a series object, we link an article to the series by passing the series ID when creating the article draft. To get the existing series, I first use this query:
query GetSeriesBySlug($slug: String!) {
me {
publications(first: 1) {
edges {
node {
series(slug: $slug) {
id
}
}
}
}
}
}
If a series already exists for the slug I'm using, I will use the corresponding ID when creating the draft. However, if a series does not exist then I create one with the following mutation:
mutation CreateSeries($input: CreateSeriesInput!) {
createSeries(input: $input) {
series {
id
}
}
}
The input for this mutation is defined as follows:
type CreateSeriesInput = {
name: string;
slug: string;
descriptionMarkdown: string;
coverImage: string;
publicationId: string;
};
Conclusion
Building series support in my Astro-powered blog was more than just a technical exercise—it was a step toward better content organization, improved discoverability, and seamless cross-platform integration. By structuring series data effectively, I’ve created a system that:
- ✅ Groups related posts under a cohesive series for easier navigation.
- ✅ Enhances content discoverability through dedicated series pages.
- ✅ Improves the reading experience with clear visual indicators and structured UI updates.
- ✅ Supports cross-posting to DEV and Hashnode, ensuring consistency across platforms.
This implementation lays the groundwork for even more advanced features, such as automated series synchronization across platforms, intelligent content recommendations, and scheduled series publishing. As I continue refining this system, I plan to explore automating updates via APIs, making it even easier to manage series content across multiple platforms.
If you’re working on a similar challenge or have insights into structuring blog series, I’d love to hear your thoughts! How do you organize related content on your blog? Let’s collaborate and build better blogging experiences together. 🚀
Top comments (0)