Jul 29th, 2024

Add Sitemaps and RSS Feeds to Your Next.js Application with Contentlayer

In the world of web development, ensuring your content is easily discoverable and accessible is crucial. Two tools that can help achieve this are sitemaps and RSS feeds. In this blog post, we'll explore how to add these features to a Next.js application built with Contentlayer.

What is a Sitemap?

A sitemap is an XML file that lists all the important pages on your website. It helps search engines understand your site structure and improve your SEO. Sitemaps can include information about each page, such as when it was last updated, how often it changes, and its relative importance on your site.

You you're targeting Google with your SEO, you can wait for google to index your website or submit the path to your sitemap via the google search console.

What is RSS?

RSS (Really Simple Syndication) is a web feed format that allows users and applications to access updates to your website in a computer-readable format. It enables your readers to stay informed about new content without having to visit your site regularly using an RSS reader.

A Few Words About Contentayer

Contentlayer is a good project for building blogs and documentation sites with Next.js. It transforms your content into type-safe JSON data, making it easy to work with your content in your React components.

However, it's important to note that as of the time of writing, Contentlayer is unmaintained, and its future is uncertain.

The good news is that there are ongoing discussions about restarting development on the project. Despite this uncertainty, Contentlayer remains a powerful tool for content management in Next.js applications.

Adding Sitemaps and RSS Feeds to Your Next.js App

In this tutorial we will add sitemaps and RSS feeds to your Next.js application using custom node scripts. This approach in this post is flexible and will work for small to medium-sized blogs. However, if you have to manage thousands of pages you probably want to automate even further (send me a DM on Twitter if this interests you).

Sitemaps

There are two approaches to adding sitemaps to Next.js + Contentlayer.

Approch 1

Next has a built in feature for sitemaps that you can find here.

Requesting the sitemap through Next's built-in feature, produced an error due to incompatible opentelemetry versions. To get it to work disable Next's telemetry reporting and add this script to /src/app/sitemap.ts.

ts
import { allBlogPosts } from "contentlayer/generated";
import { MetadataRoute } from "next";
export function getBaseUrl() {
return process.env.NODE_ENV === "production"
? "<YOUR_PRODUCTION_URL>"
: "http://localhost:3000";
}
const basePath = getBaseUrl();
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: basePath + "/",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
}, //@ts-ignore
...allBlogPosts.map(({ urlPath, date }) => ({
url: basePath + urlPath,
lastModified: new Date(date),
changeFrequency: "monthly",
priority: 0.8,
})),
];
}
ts
import { allBlogPosts } from "contentlayer/generated";
import { MetadataRoute } from "next";
export function getBaseUrl() {
return process.env.NODE_ENV === "production"
? "<YOUR_PRODUCTION_URL>"
: "http://localhost:3000";
}
const basePath = getBaseUrl();
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: basePath + "/",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
}, //@ts-ignore
...allBlogPosts.map(({ urlPath, date }) => ({
url: basePath + urlPath,
lastModified: new Date(date),
changeFrequency: "monthly",
priority: 0.8,
})),
];
}

Approch 2

Add the following script to /scripts/sitemap.mjs.

js
import { writeFileSync } from "node:fs";
import prettier from "prettier";
import { allBlogPosts } from "../.contentlayer/generated/index.mjs";
export function getBaseUrl() {
return process.env.NODE_ENV === "production"
? "<PRODUCTION_URL>"
: "http://localhost:3000";
}
export function formatDate(date) {
return date.toISOString().split(".")[0] + "+00:00";
}
try {
const urlPaths = allBlogPosts
.map(
(blogPost) => `
<url>
<loc>${getBaseUrl()}${blogPost.urlPath}</loc>
<lastmod>${formatDate(new Date(blogPost.date))}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
`
)
.join("");
const sitemap = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${getBaseUrl()}</loc>
<lastmod>${formatDate(new Date())}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
${urlPaths}
</urlset>
`;
const prettierConfig = await prettier.resolveConfig("./prettierrc");
const formatted = await prettier.format(sitemap, {
...prettierConfig,
parser: "html",
});
writeFileSync("public/sitemap.xml", formatted);
console.log("Success: sitemap created successfully");
} catch (err) {
console.error("Error: Unable to create sitemap");
}
js
import { writeFileSync } from "node:fs";
import prettier from "prettier";
import { allBlogPosts } from "../.contentlayer/generated/index.mjs";
export function getBaseUrl() {
return process.env.NODE_ENV === "production"
? "<PRODUCTION_URL>"
: "http://localhost:3000";
}
export function formatDate(date) {
return date.toISOString().split(".")[0] + "+00:00";
}
try {
const urlPaths = allBlogPosts
.map(
(blogPost) => `
<url>
<loc>${getBaseUrl()}${blogPost.urlPath}</loc>
<lastmod>${formatDate(new Date(blogPost.date))}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
`
)
.join("");
const sitemap = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${getBaseUrl()}</loc>
<lastmod>${formatDate(new Date())}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
${urlPaths}
</urlset>
`;
const prettierConfig = await prettier.resolveConfig("./prettierrc");
const formatted = await prettier.format(sitemap, {
...prettierConfig,
parser: "html",
});
writeFileSync("public/sitemap.xml", formatted);
console.log("Success: sitemap created successfully");
} catch (err) {
console.error("Error: Unable to create sitemap");
}

Next, add the following script command to package.json.

json
{
"scripts": {
"build:sitemap": "node scripts/sitemap.mjs",
}
}
json
{
"scripts": {
"build:sitemap": "node scripts/sitemap.mjs",
}
}

In this script we're using the build output of Contentlayer to retrieve all our blog posts. We then create a list of sitemap entries from out blog posts (we're adding the index page manually).

Note: The node: import prefix is part of Node.js' module resolution algorithm. It ensures that we're using the built-in Node.js fs module, avoiding potential conflicts with third-party modules that might have the same name. This is a good practice for clarity and consistency.

RSS

To add RSS add the following script to /scripts/rss.mjs.

js
import { writeFileSync } from "fs";
import RSS from "rss";
import { allBlogPosts } from "../.contentlayer/generated/index.mjs";
import { getBaseUrl } from "./sitemap.mjs";
const baseUrl = getBaseUrl();
const feed = new RSS({
title: "<NAME OF YOUR BLOG>",
feed_url: `${baseUrl}/rss.xml`,
site_url: baseUrl,
// image_url: <OPTIONAL_ICON_URL>
});
try {
allBlogPosts
.map((blogPost) => ({
title: blogPost.title,
description: blogPost.excerpt,
url: `${baseUrl}${blogPost.urlPath}`,
date: new Date(blogPost.date),
}))
.forEach((item) => {
feed.item(item);
});
writeFileSync("./public/rss.xml", feed.xml({ indent: true }));
console.log("Success: RSS feed created successfully");
} catch (err) {
console.error("Error: Unable to create RSS feed");
}
js
import { writeFileSync } from "fs";
import RSS from "rss";
import { allBlogPosts } from "../.contentlayer/generated/index.mjs";
import { getBaseUrl } from "./sitemap.mjs";
const baseUrl = getBaseUrl();
const feed = new RSS({
title: "<NAME OF YOUR BLOG>",
feed_url: `${baseUrl}/rss.xml`,
site_url: baseUrl,
// image_url: <OPTIONAL_ICON_URL>
});
try {
allBlogPosts
.map((blogPost) => ({
title: blogPost.title,
description: blogPost.excerpt,
url: `${baseUrl}${blogPost.urlPath}`,
date: new Date(blogPost.date),
}))
.forEach((item) => {
feed.item(item);
});
writeFileSync("./public/rss.xml", feed.xml({ indent: true }));
console.log("Success: RSS feed created successfully");
} catch (err) {
console.error("Error: Unable to create RSS feed");
}

Next add the following script command to package.json.

json
{
"scripts": {
"build:rss": "node scripts/rss.mjs"
}
}
json
{
"scripts": {
"build:rss": "node scripts/rss.mjs"
}
}

This script is similar to the script for sitemaps from approach 2. We're using the output of Contentlayer to produce our RSS definition.

Instead of creating the XML manually we're relying on the library rss.

Add to Your Build

Finally, add the two new scripts to your build step:

json
{
"scripts": {
"build": "next build && npm run build:sitemap && npm run build:rss"
}
}
json
{
"scripts": {
"build": "next build && npm run build:sitemap && npm run build:rss"
}
}

Add auto discovery

In order for some RSS Readers to find your blog, you go src/app/page.tsx (use the <Head /> component if you are using the pages router). Next, add this to the top level (below your import declarations) of your file.

ts
export const metadata: Metadata = {
alternates: {
types: {
"application/rss+xml": [
{
url: "<FULL_PRODUCTION_URL>/rss.xml",
title: "<NAME>",
},
],
},
},
};
// export default Page ...
ts
export const metadata: Metadata = {
alternates: {
types: {
"application/rss+xml": [
{
url: "<FULL_PRODUCTION_URL>/rss.xml",
title: "<NAME>",
},
],
},
},
};
// export default Page ...

We were fed up with unclear API definitions and bad APIs

So we created a better way. API-Fiddle is an API design tool with first-class support for DTOs, versioning, serialization, suggested response codes, and much more.