Crossposting articles from Gatsby to Medium, Dev.to, and Hashnode

Syndicate your content to other sites to increase your audience and engage more people.

Mihai Bojin
7 min readAug 22, 2021
Photo: Distribute news
“Photo by Markus Spiske on Unsplash

🔔 This article was originally posted on my site, MihaiBojin.com. 🔔

As sole authors, especially when starting up, we don’t have established audiences. Without a critical mass of people to reach, the content we put so much love and care into ends up being ignored and not helping anyone. This is tough to deal with from a psychological level, but also what many people call ‘the grind.’

With all that, it’s essential to take any opportunity to reach a broader audience. One solution is to crosspost our content on various distribution platforms.

Since I write for software developers, my go-to places are Medium.com, Dev.To, and Hashnode.com.

Generally, this is a two-step process:

  1. create an RSS feed containing your most recent articles
  2. (a) have the destination monitor the feed and pull new articles or (b) use automation tools to push new content using an API

Let’s dig in!

Publishing an RSS feed from GatsbyJS

Start by installing one of Gatsby’s plugins:

npm install gatsby-plugin-feed

Once installed, add it to your gatsby-config.js file:

module.exports = {
plugins: [
{
resolve: `gatsby-plugin-feed`,
options: {
query: ``,
setup(options) {
return {};
},
feeds: [
{
title: "RSS feed",
output: '/rss.xml',
query: ``,
serialize: ({ query: {} }) => {},
},
],
},
},
],
};

The configuration above is not enough to generate a usable feed.

Let me break it down, option-by-option.

Note: when I first implemented the RSS feed on my site, I struggled to find examples that explained how I could achieve everything I wanted. I hope this article will help others avoid my experience.

Retrieve site metadata

First, each feed will need to access the site’s metadata. The format of your site’s metadata can be different, but generally, the GraphQL query to select it will look as follows. If it doesn’t work, you can always start your GatsbyJS server in development mode (gatsby develop) and debug the query using the GraphiQL explorer.

module.exports = {
plugins: [
{
resolve: `gatsby-plugin-feed`,
options: {
query: `
{
site {
siteMetadata {
title
description
author {
name
}
siteUrl
}
}
}
`,
},
},
],
};

Setup

The gatsby-plugin-feed relies on a few pre-defined (but not well-documented) field names to build the resulting RSS.

Below, you can see a snippet with line-by-line explanations:

module.exports = {
plugins: [
{
resolve: `gatsby-plugin-feed`,
options: {
setup(options) {
return Object.assign({}, options.query.site.siteMetadata, {
// make the markdown available to each feed
allMarkdownRemark: options.query.allMarkdownRemark,
// note the <generator> field (optional)
generator: process.env.SITE_NAME,
// publish the site author's name (optional)
author: options.query.site.siteMetadata.author.name,
// publish the site's base URL in the RSS feed (optional)
site_url: options.query.site.siteMetadata.siteUrl,
custom_namespaces: {
// support additional RSS/XML namespaces (see the feed generation section below)
cc:
'http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html',
dc: 'http://purl.org/dc/elements/1.1/',
media: 'http://search.yahoo.com/mrss/',
},
});
},
},
},
],
};

Select articles to include in the RSS feed

This and the next sections are highly specific to your site and are based on how you set up your posts’ frontmatter. You will likely need to customize these, but they should hopefully serve as an example of how to generate a feed will all the necessary article data.

The provided GraphQL query filters articles with includeInRSS: true in their frontmatter and sorts the results by publishing date (frontmatter___date), most recent first.

Since articles on my site support a featured (cover) image (featuredImage {...}), we want to select and include it in the feed, along with the alt and title fields.

We also select several other fields from frontmatter and some made available by allMarkdownRemark.

module.exports = {
plugins: [
{
resolve: `gatsby-plugin-feed`,
options: {
feeds: [
{
query: `
{
allMarkdownRemark(
filter: { frontmatter: { includeInRSS: { eq: true } } }
sort: { order: DESC, fields: [frontmatter___date] },
) {
nodes {
excerpt
html
rawMarkdownBody
fields {
slug
}
frontmatter {
title
description
date
featuredImage {
image {
childImageSharp {
gatsbyImageData(layout: CONSTRAINED)
}
}
imageAlt
imageTitleHtml
}
category {
title
}
tags
}
}
}
}
`,
},
],
},
},
],
};

Serializing the necessary data

The last step in the generation process is to merge all the available data and generate complete feed entries. This is achieved during serialization:

module.exports = {
plugins: [
{
resolve: `gatsby-plugin-feed`,
options: {
feeds: [
{
serialize: ({ query: { site, allMarkdownRemark } }) => {
// iterate and process all nodes (articles)
return allMarkdownRemark.nodes.map((node) => {
// store a few shorthands that we'll need multiple times
const siteUrl = site.siteMetadata.siteUrl;
const authorName = site.siteMetadata.author.name;

// populate the canonical URL
const articleUrl = `${siteUrl}${node.fields.slug}`;

// retrieve the URL (src=...) of the article's cover image
const featuredImage =
siteUrl +
node.frontmatter.featuredImage?.image
.childImageSharp.gatsbyImageData.images.fallback
.src;

// augment each node's frontmatter with extra information
return Object.assign({}, node.frontmatter, {
// if a description isn't provided,
// use the auto-generated excerpt
description:
node.frontmatter.description || node.excerpt,
// article link, used to populate canonical URLs
link: articleUrl,
// trick: you also need to specify the 'url' attribute so that the feed's
// guid is labeled as a permanent link, e.g.: <guid isPermaLink="true">
url: articleUrl,
// specify the cover image
enclosure: {
url: featuredImage,
},
// process local tags and make them usable on Twitter
// note: we're publishing tags as categories, as per the RSS2 spec
// see: https://validator.w3.org/feed/docs/rss2.html#ltcategorygtSubelementOfLtitemgt
categories: node.frontmatter.tags
.map((tag) => makeTwitterTag(tag))
// only include the 5 top-most tags (most platforms support 5 or less)
.slice(0, 5),
custom_elements: [
// advertise the article author's name
{ author: site.siteMetadata.author.name },
// supply an image to be used as a thumbnail in your RSS (optional)
{
'media:thumbnail': {
_attr: { url: featuredImage },
},
},
// specify your content's license
{
'cc:license':
'https://creativecommons.org/licenses/by-nc-sa/4.0/',
},
// advertise the site's primary author
{
'dc:creator': renderHtmlLink({
href: siteUrl,
title: process.env.SITE_NAME,
text: authorName,
}),
},
// the main article body
{
'content:encoded':
// prepend the feature image as HTML
generateFeaturedImageHtml({
src: featuredImage,
imageAlt:
node.frontmatter.featuredImage?.imageAlt,
imageTitleHtml:
node.frontmatter.featuredImage?.imageTitleHtml
}) +
// append the content, fixing any relative links
fixRelativeLinks({
html: node.html,
siteUrl: site.siteMetadata.siteUrl,
}),
},
],
});
});
},
},
],
},
},
],
};

There are a few caveats here:

I’m using a few custom functions I wrote, here is the source code:

// Generates HTML for the featured image, to prepend it to the node's HTML
// so that sites like Medium/Dev.to can include the image by default
function generateFeaturedImageHtml({
src,
imageAlt,
imageTitleHtml,
}) {
const caption = imageTitleHtml
? `<figcaption>"${imageTitleHtml}"</figcaption>`
: '';
return `<figure><img src="${src}" alt="${imageAlt}" />${caption}</figure>`;
}

// Takes a tag that may contain multiple words and
// returns a concatenated tag, with every first letter capitalized
function makeTwitterTag(tag) {
const slug = tag
.replaceAll(/[^\w]+/g, ' ')
.split(/[ ]+/)
.map((word) => upperFirst(word))
.join('');
if (slug.length === 0) {
throw new Error(
`Invalid tag, cannot create empty slug from: ${tag}`,
);
}
return slug;
}

// Prepends the siteURL on any relative links
function fixRelativeLinks({ html, siteUrl }) {
// fix static links
html = html.replace(
/(?<=\"|\s)\/static\//g,
`${siteUrl}\/static\/`,
);

// fix relative links
html = html.replace(/(?<=href=\")\//g, `${siteUrl}\/`);

return html;
}

Most of the extra tags are optional but can be helpful if your RSS gets redistributed further, in which case you’d want users to have as much metadata about you and your site as possible!

We prepend the cover image using generateFeaturedImageHtml, because Medium's API does not support the cover image as a field. However, it parses the first image of the submitted raw content and stores it as a cover. This qualifies as a 'trick' 😊.

Since GatsbyJS uses React routes, all internal links will end up as relative links in the RSS. We fix this by prepending the site URL on any such links (this includes images), using the fixRelativeLinks function. (Shoutout to Mark Shust, who documented how to fix this, first!)

When specifying tags in your frontmatter, define the most relevant ones up-top. For example, Medium only supports five tags, and DevTo supports four, which is why we truncate the number of tags(categories) included in the RSS.

Uploading articles to various distribution platforms

Dev.to

Dev.to supports publishing to DEV Community from RSS. This is very easy to set up once you’ve done all the above. They will parse your RSS feed and create draft articles from new content. You can then review and publish these in the DEV community!

Medium.com

Medium does not support reading RSS feeds, so in this case, we have to rely on Zapier to crosspost.

I won’t repeat the same content here; please see the tutorial linked above for details on how to configure Zapier, and have it crosspost your content, for free!

Hashnode.com

The more recent platform I’m crossposting to is Hashnode.com.

Since I’ve been using Zapier for Medium for a while, I thought it would be fun to build a Zapier integration. I started with their UI and later exported and completed the code, so that everyone can use it!

If you’re a developer, feel free to clone or fork my Zapier integration, deploy it in your account, and use it to crosspost on Zapier.com.

Unfortunately, Zapier requires any officially published integrations to be supported by the destination platform (in this case, Hashnode). I’ve reached out to ask them to take over this code and make it available for their community, but so far, I haven’t heard back.

Conclusion

I hope this helps GatsbyJS users properly define RSS feeds and successfully crosspost their articles to reach a wider audience. If you follow this tutorial and struggle, please reach out via DM!

Thanks!

If you liked this article and want to read more like it, please subscribe to my newsletter; I send one out every few weeks!

--

--

Mihai Bojin

Software Engineer at heart, Manager by day, Indie Hacker at night. Writing about DevOps, Software engineering, and Cloud computing. Opinions my own.