Eric Nishio

How to Serve a Sitemap.xml with Remix

React

This is a guide on how to implement a dynamic sitemap.xml resource for your Remix website.

First, let’s create a file called app/routes/[sitemap.txt].jsx (including the square brackets). This format follows v2 of Remix's file-based route naming convention.

Open the file, and write a loader function that handles requests that match the route you just declared:

jsx
export const loader = async () => {
  try {
    return new Response('foobar', {
      status: 200,
      headers: {
        'Content-Type': 'application/xml',
        'X-Content-Type-Options': 'nosniff',
        'Cache-Control': 'public, max-age=3600',
      },
    })
  } catch (e) {
    throw new Response('Internal Server Error', { status: 500 })
  }
}

Now you should have a public GET endpoint that outputs “foobar”. This is how you declare general purpose resource routes with Remix. You can also use the same technique to serve a static robots.txt resource.

Next, we need to write the code that generates the XML. It will depend on what kind of route configuration you have. For example, if you have a blog you’ll need to put together a complete list of blog post URLs. Additionally, you’ll want to include any static routes, like an about page and the page that showcases your blog posts.

Here’s how I’ve done it for this website. I use Contentful for all dynamic content on the website, including blog posts, so I won’t go into the implementation details since they’re very specific to this website. It’s just a GraphQL query that returns the slug of every published blog post.

First, let’s declare our site-wide reusables in a constants.js file:

jsx
export const { BASE_URL } = process.env

export const navbar = [
  { to: '/', text: 'Home' },
  { to: '/blog', text: 'Blog' },
  { to: '/portfolio', text: 'Portfolio' },
  { to: '/about', text: 'About' },
]

This navbar list contains text labels because it’s also used by the UI component. Having one shared navbar struct keeps the UI and sitemap consistent.

Then create a formatter function that loops over a list of URLs and outputs a raw XML template containing the URL nodes:

jsx
export const toXmlSitemap = (urls) => {
  const urlsAsXml= urls.map(url => `<url><loc>${url}</loc></url>`).join('\n')

  return `
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset
      xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
    >
      ${urlsAsXml}
    </urlset>
  `
}

Then merge the navbar links and blog posts together and feed them to the aforementioned toXmlSitemap function:

jsx
const sitemap = toXmlSitemap([
  ...navbar.filter(({ to }) => to !== '/').map(({ to }) => `${BASE_URL}${to}`),
  ...blogPosts.map(({ slug }) => `${BASE_URL}/blog/${slug}`),
])

Finally, we simply need to pass the generated XML to the response. The loader function should look something like this:

jsx
import { BASE_URL, navbar } from '~/constants'
import { getAllBlogPostSlugs } from '~/content/graphql.server'

export const loader = async () => {
  try {
    const blogPosts = await getAllBlogPostSlugs()

    const sitemap = toXmlSitemap([
      ...navbar.filter(({ to }) => to !== '/').map(({ to }) => `${BASE_URL}${to}`),
      ...blogPosts.map(({ slug }) => `${BASE_URL}/blog/${slug}`),
    ])

    return new Response(sitemap, {
      status: 200,
      headers: {
        'Content-Type': 'application/xml',
        'X-Content-Type-Options': 'nosniff',
        'Cache-Control': 'public, max-age=3600',
      },
    })
  } catch (e) {
    throw new Response('Internal Server Error', { status: 500 })
  }
}

Now your /sitemap.xml route should render a dynamic sitemap.

Japanese Wax Seal: NishioCopyright 2024 Eric Nishio
XInstagramGitHubLinkedIn