004
benjamin katzdecember 2, 2024
Programmatic SEO with Next.js 15
Creating hundreds of programmatically generated, search engine optimized pages with Next.js 15, React Server Components, and the app router.
Introduction
Next.js 15's support for React Server Components combined with the app router's dynamic routing make it easy to build hundreds or even thousands of dynamically generated pages. This includes dynamic content, a dynamic sitemap, dynamic opengraph images, and dynamic SEO metadata. I know, that's a lot of dynamic, but once it all comes together, you'll see how easy it is turn Next.js into a programmatic SEO powerhouse.
What we'll be building
To showcase the power of programmatic SEO, we'll be building dynamically generated pages for all 118 elements on the periodic table. Here's what it will look like:
For this guide I'm calling the app
Obviously, for your own project you will want to think carefully about what content you use to populate your pages. This will likely be based on your SEO strategy, and what you know your audience will be searching for.
If you need inspiration, one my favourite examples is Canva, who's team is absolutely killing the pSEO game with dynamic pages for every type of graphic design template, format, and use case you can think of. This guide will focus mainly on the technical aspects of programmatic SEO, so I highly reccomend doing some reading or consulting an SEO expert if you don't yet have a solid SEO strategy.
The data for this guide comes in at around
Now, if we navigate to
4. Fetch the page data
While this dynamic route works, the user can technically type in anything and the page will display it. We need to instead use the slug to fetch actual element data from our
We're doing a couple of things here that are worth calling out:
It works! We see the symbol for gold, dynamically fetched from our
5. Define a content strategy
We now have all the pieces needed to add more content to the page. How you do this will depend on your SEO strategy. Generally, you should think about clever ways to use the data to construct content that will rank for high volume search terms. If you constructed your own content database / dataset, it's likely you've already thought of how you want to do this. But let's continue with our periodic table example.
What questions are people asking?
After doing some keyword research using ahrefs, I've identified some queries that could be answered with the data available to us. Here's a screenshot from ahrefs, a popular SEO tool, to give you an idea of what I mean:
Update the page
Next, I'll update the
Test the page
Now, if we load the page for Silver (or any element for that matter), we should see our beautiful new dynamic page!
As you can see, I've added three FAQ questions to the page based on our keyword research. These questions are dynamically constructed using JavaScript template literals (highlighted in blue):
7. Generate dynamic SEO metadata
While this great, we still don't have any SEO metadata, or a sitemap. Without these things, search engines have no idea that our 118 different element pages even exist! Let's now use a similar templating technique to generate the metadata for each page, and then add a dynamic sitemap.
Define the
Now, if we check the
Generate dynamic opengraph images
Opengraph is a protocol that allows us to define a "social media preview" for our page, which is displayed when someone shares a link. We'll be using Next.JS's built in image generation to automatically generate an OG image for each page.
To generate a dynamic opengraph image for our page, we need to add a
Note that in this file we're using the
Keep in mind, the URL will be different in your case, as it's dynamically generated - so inspect the page source to test it out. You can also use an OG image checker to make sure your image is working, but this will only work once you've deployed your app publicly.
Create a dynamic sitemap
Creating a dynamic sitemap with Next.js is easy. It's a perfect example of why Next.js is so powerful, and why it's a great choice for programmatic SEO. Simply create a
Now if we load
That's where we'll leave our metadata at for now, however there's much more you could do. The principles we've covered should give you with all you need to start experimenting on your own. Let's move on to the finishing touches, and then deployment!
8. Create a home page
The last thing we'll do is a home page where you can see all the elements on the periodic table (or in a list view on mobile). This is obviously good for user experience, but internal linking is also a neccesary practice to help search engines understand your site.
Here's the code for our periodic table home page:
Now, let's load up the home page and see it in action!
9. Deploy to Vercel
We're ready to deploy! Vercel is the easiest (and best) choice for Next.js, so that's what we'll be using. Now is a good time to get the full code for this project. If you'd like, you can clone this GitHub repository.
When you're ready to deploy, push your code to a new GitHub repo. Then, sign up or log in to Vercel, and create a new project. Select your repo from the list, click "Deploy", and your project will be live!
I went ahead and added a domain to my project, as well: atomicchart.com.
If you set up a custom domain (or if you're using a Vercel subdomain), remember to update the base URL in the
1. Create a Next.js 15 app
The first thing we need to do is generate a new Next.js app (if you don't have one already). Next.js 15 is now stable, so we can simply use:
npx create-next-app@latest atomic-chart
atomic-chart
(you'll see why later), but you can call it whatever you'd like.
Hit enter to accept the defaults, and then navigate into the newly created atomic-chart
folder (or open it in your editor of choice).
2. Prepare the page data
Before we start building dynamic pages, we need to prepare the data that we'll use to populate them. For this guide, I'll be using Periodic-Table-JSON, a list of every element on the periodic table and their properties - hence the name atomic-chart
.
Let's begin by creating a new /data
folder in our Next.js app's /src
folder, and adding our elements.json
file:
src/data/elements.json
12345678910111213141516
{
"elements": [
{
"name": "Hydrogen",
"appearance": "colorless gas",
"atomic_mass": 1.008,
"boil": 20.271,
"category": "diatomic nonmetal",
"density": 0.08988,
... REMAINING PROPERTIES OMITTED FOR BREVITY
},
... REMAINING ELEMENTS OMITTED FOR BREVITY
]
}
275 KB
. Because we will be using server components, it's not a huge deal that it's in JSON format. When we load a page for a specific element, only the data for that element will be loaded delivered to the client. However, if you plan on having more than a few hundred pages, you may want to use a more performant database. A programmatic SEO implementation like Canva's is likely using a relational database or a CMS. I'll do my best throughout this guide to point out where code would change if you were using a database or CMS instead.
3. Create a dynamic route
Now that we have our list of elements, we need to create a dynamic route, so that when we visit ../elements/[element]
, we get a page with the data for that element. For example, accessing the ../elements/hydrogen
URL should respond with a page for the element Hydrogen. We can do this easily with the app router in Next.js 15.
Create a new /elements
folder in your app
folder, then add another folder inside of that called [slug]
, containing a new page.tsx
file:
src/app/elements/[slug]/page.tsx
123456789101112
export default async function ElementPage({params}: {params: Promise<{ slug: string }>}) {
// Get the element name from the URL
const slug = (await params).slug
return (
<main className="flex flex-col items-center justify-center h-screen">
<h1 className="text-4xl font-bold">{slug}</h1>
</main>
)
}
localhost:3000/elements/hydrogen
, we should see this:
localhost:3000/elements/hydrogen
elements.json
file. If the fetch succeeds, we'll display the data on the page. If it fails, we'll display an "Element Not Found" page.
src/app/elements/[slug]/page.tsx
12345678910111213141516171819202122232425262728
// Import the element data
import elementData from "@/data/elements.json"
export default async function ElementPage({params}: {params: Promise<{ slug: string }>}) {
// Get the element name from the URL
const slug = (await params).slug.toLowerCase()
const element = elementData.elements.find((element) => element.name.toLowerCase() === slug)
// If the element is not found, return a not found page
if (!element) {
return (
<div className="flex flex-col gap-2 items-center justify-center h-screen">
<h1 className="text-4xl font-bold text-center">This element doesn't exist!</h1>
<p className="text-center">yet...</p>
</div>
)
}
// Otherwise we can freely use the element data in our page!
return (
<div className="flex flex-col gap-2 items-center justify-center h-screen">
<span className="text-8xl">{element?.symbol}</span>
<h1 className="text-4xl font-bold">{element?.name}</h1>
</div>
)
}
-
We are directly indexing the imported elementDatawith theslugas the index. If you were using a database or CMS, this is where you'd need to instead make your database query, withslugas a query parameter.
-
Typescript's powerful type inference allows us to use the elementDataobject without any type casting. This is incredibly useful, as we can use intellisense in VSCode to see what properties we can display on the page, without needing to manually type our data.
-
Using the toLowerCasemethod in the conditions of ourgetElement()utility function ensures that the search is case-insensitive.
/elements/gold
. If all is working correctly, we should see the element's symbol:
localhost:3000/elements/gold
elements.json
file! But if you try to view an element that doesn't exist, you'll see the 404 page:
localhost:3000/elements/nextite
As you can see, there's a reasonable amount of search volume for queries like: "what is ag on the periodic table". For just one element there's not much search volume, but because we can construct pages dynamically, we can create a page for every single one of these queries. In total, we could potentially capture 118 * ~500 = ~59,000 searches per month, and that's just for those specific queries - there's also the reverse direction, e.g. "what is the symbol for silver", as well as more technical queries like "what is the atomic mass of silver" that we can answer with our data.
That's huge traffic potential for content we can generate automatically. We can define our "template" once, and generate search engine optimized pages for every single one of our target queries.
6. Build the dynamic page template
Now that we have an idea of our content strategy for
atomic-chart
, let's build out the page template with more data, and do some proper styling.
Update the layout
The first thing I'll do is clean up the default Next.js layout, including swapping out the default Next.js font for a Google Font called Space Mono, and adding some basic metadata (we'll come back to metadata in a bit).
src/app/layout.tsx
123456789101112131415161718192021222324252627282930313233343536
import type { Metadata } from "next";
import Link from "next/link";
import { Space_Mono } from "next/font/google";
import "./globals.css";
const spaceMono = Space_Mono({
subsets: ["latin"],
variable: "--font-space-mono",
weight: ["400", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "Atomic Chart",
description: "A chart of the elements.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${spaceMono.className} antialiased text-black dark:text-white`}>
<main className="w-full max-w-screen-xl mx-auto flex flex-col gap-4 items-center h-screen p-4 sm:p-12 lg:p-24">
<header className="flex w-full max-w-screen-xl mx-auto justify-between items-center border border-black dark:border-white px-6 py-4">
<Link href="/" className="text-lg font-bold">atomic-chart.com</Link>
<span className="text-sm">v0.1.0</span>
</header>
{children}
</main>
</body>
</html>
);
}
page.tsx
file to display the element's symbol, name, and atomic number in a simple card, mimicing the design of the periodic table. I'll also add some "FAQ" content, to align with the queries we identified earlier.
src/app/elements/[slug]/page.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Import the element data
import elementData from "@/data/elements.json"
export default async function ElementPage({ params }: { params: Promise<{ slug: string }> }) {
// Get the element name from the URL
const slug = (await params).slug.toLowerCase()
const element = elementData.elements.find((element) => element.name.toLowerCase() === slug)
// If the element is not found, return a not found page
if (!element) {
return (
<div className="flex flex-col gap-2 items-center justify-center h-screen">
<h1 className="text-4xl font-bold text-center">This element doesn't exist!</h1>
<p className="text-center">yet...</p>
</div>
)
}
// Otherwise we can freely use the element data in our page!
return (
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col lg:flex-row w-full lg:max-h-[300px] gap-4">
<div className="relative w-full h-full lg:max-w-[300px] aspect-square p-8 flex flex-col justify-center items-center border border-black dark:border-white">
<span className="absolute top-8 left-8 text-xl">{element?.number}</span>
<div className="flex flex-col items-center gap-4 pb-8">
<span className="text-7xl font-bold">{element?.symbol}</span>
<h1 className="text-xl">{element?.name}</h1>
</div>
<span className="absolute bottom-8 text-sm">{element?.atomic_mass}</span>
</div>
<div className="w-full min-w-0 p-8 flex flex-col justify-between gap-2 border border-black dark:border-white">
<div className="flex flex-col gap-0.5">
<span className="font-bold">Appearance</span>
<span className="text-sm">{element?.appearance}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Category</span>
<span className="text-sm">{element?.category}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Discovered by</span>
<span className="text-sm">{element?.discovered_by || 'N/A'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Named by</span>
<span className="text-sm">{element?.named_by || 'N/A'}</span>
</div>
</div>
</div>
<div className="w-full min-w-0 p-8 flex flex-col gap-6 border border-black dark:border-white">
<div className="flex flex-col gap-1">
<span className="font-bold">What element is {element?.symbol} on the periodic table?</span>
<span className="text-sm">{element?.symbol} is the element {element?.name} on the periodic table.</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-bold">What is the symbol for {element?.name} on the periodic table?</span>
<span className="text-sm">The symbol for {element?.name} on the periodic table is {element?.symbol}.</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-bold">What does {element?.name} look like?</span>
<span className="text-sm">To the human eye, {element?.name} is {element?.appearance}.</span>
</div>
</div>
</div>
)
}
localhost:3000/elements/silver
What element is {element?.symbol} on the periodic table?{element?.symbol} is the element {element?.name} on the periodic table.What is the symbol for {element?.name} on the periodic table?The symbol for {element?.name} on the periodic table is {element?.symbol}.What does {element?.name} look like?To the human eye, {element?.name} is {element?.appearance}.
This type of templating is really the core of our programmatic SEO implementation in Next.js, so I highly reccomend getting familar with it, and thinking of creative ways to mix and match your data together to build good content.
Bonus: there's dark mode, too!
Because Next.js comes with Tailwind by default (and some basic dark mode styling in global.css), by using the dark:
class where necessary, we can have complete dark mode support!
localhost:3000/elements/silver
generateMetadata()
function
Let's start by adding a generateMetadata()
function to our page.tsx
file. This is a Next.js 15 feature that allows us to dynamically generate metadata for each page. Whatever is returned from this function will be inserted into the <head>
tag of the page and be used as the page's SEO metadata.
Here's the full page.tsx
file with the generateMetadata()
function added:
src/app/elements/[slug]/page.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// Import the element data
import elementData from "@/data/elements.json"
// Function to generate metadata for the page
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const slug = (await params).slug
const element = elementData.elements.find((element) => element.name.toLowerCase() === slug.toLowerCase())
const title = `What is ${element?.symbol} on the periodic table?`
const description = `${element?.symbol} is the element ${element?.name} on the periodic table.`
const keywords = [element?.name, element?.symbol, "periodic table", "element", "chemistry"]
return {
title: title,
description: description,
keywords: keywords,
openGraph: {
title: title,
description: description,
siteName: "Atomic Chart"
},
twitter: {
title: title,
description: description
}
}
}
export default async function ElementPage({ params }: { params: Promise<{ slug: string }> }) {
// Get the element name from the URL
const slug = (await params).slug
const element = elementData.elements.find((element) => element.name.toLowerCase() === slug.toLowerCase())
// If the element is not found, return a not found page
if (!element) {
return (
<div className="flex flex-col gap-2 items-center justify-center h-screen">
<h1 className="text-4xl font-bold text-center">This element doesn't exist!</h1>
<p className="text-center">yet...</p>
</div>
)
}
// Otherwise we can freely use the element data in our page!
return (
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col lg:flex-row w-full lg:max-h-[300px] gap-4">
<div className="relative w-full h-full lg:max-w-[300px] aspect-square p-8 flex flex-col justify-center items-center border border-black dark:border-white">
<span className="absolute top-8 left-8 text-xl">{element?.number}</span>
<div className="flex flex-col items-center gap-4 pb-8">
<span className="text-7xl font-bold">{element?.symbol}</span>
<h1 className="text-xl">{element?.name}</h1>
</div>
<span className="absolute bottom-8 text-sm">{element?.atomic_mass}</span>
</div>
<div className="w-full min-w-0 p-8 flex flex-col justify-between gap-2 border border-black dark:border-white">
<div className="flex flex-col gap-0.5">
<span className="font-bold">Appearance</span>
<span className="text-sm">{element?.appearance}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Category</span>
<span className="text-sm">{element?.category}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Discovered by</span>
<span className="text-sm">{element?.discovered_by || 'N/A'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="font-bold">Named by</span>
<span className="text-sm">{element?.named_by || 'N/A'}</span>
</div>
</div>
</div>
<div className="w-full min-w-0 p-8 flex flex-col gap-6 border border-black dark:border-white">
<div className="flex flex-col gap-1">
<span className="font-bold">What element is {element?.symbol} on the periodic table?</span>
<span className="text-sm">{element?.symbol} is the element {element?.name} on the periodic table.</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-bold">What is the symbol for {element?.name} on the periodic table?</span>
<span className="text-sm">The symbol for {element?.name} on the periodic table is {element?.symbol}.</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-bold">What does {element?.name} look like?</span>
<span className="text-sm">To the human eye, {element?.name} is {element?.appearance}.</span>
</div>
</div>
</div>
)
}
<head>
tag of the page, we can see that Next.js has inserted the metadata we defined in the generateMetadata()
function!
<head> tag
123456789
<title>What is Ag on the periodic table?</title>
<meta name="description" content="Ag is the element Silver on the periodic table.">
<meta name="keywords" content="Silver,Ag,periodic table,element,chemistry">
<meta property="og:title" content="What is Ag on the periodic table?">
<meta property="og:description" content="Ag is the element Silver on the periodic table.">
<meta property="og:site_name" content="Atomic Chart">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="What is Ag on the periodic table?">
<meta name="twitter:description" content="Ag is the element Silver on the periodic table.">
opengraph-image.tsx
file to the [slug]
folder. In this file we can use our element data similar to the page.tsx
file. Here's the full code:
src/app/elements/[slug]/opengraph-image.tsx
123456789101112131415161718192021222324252627282930313233
import { ImageResponse } from 'next/og'
import elementData from "@/data/elements.json"
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const slug = (await params).slug.toLowerCase()
const element = elementData.elements.find((element) => element.name.toLowerCase() === slug)
return new ImageResponse(
(
<div tw="text-4xl w-full h-full bg-white flex items-center justify-center">
<div tw="relative w-full h-full p-8 flex flex-col justify-center items-center">
<span tw="absolute top-12 left-12 text-6xl">{element?.number}</span>
<div tw="flex flex-col items-center pb-8">
<span tw="text-[148px] leading-[1] font-bold">{element?.symbol}</span>
<span tw="text-[44px]">{element?.name}</span>
</div>
<span tw="absolute bottom-12 text-2xl">{element?.atomic_mass}</span>
</div>
</div>
),
{
...size,
}
)
}
tw="..."
prop to apply tailwind styles rather than the typical className="..."
, as Image Generation works slightly differently than a standard Next.js page. You'll also notice in a moment that the font seems different in the OG image, which is again due to differences between dynamic image generation and standard Next.js pages. If you're interested in learning more about the differences between the two, I'd recommend reading the official documentation. For now, we'll leave this implementation as is, as it's good enough for our purposes.
If we inspect the page source in our browser, we can see that an og:image tag has been added to the <head>
. And if we visit the URL of the og:image, we see it working!
localhost:3000/elements/silver/opengraph-image?70e8388d5ea58223
sitemap.ts
file in your app
folder:
src/app/sitemap.ts
123456789101112131415161718192021222324252627282930
import { MetadataRoute } from 'next'
// Import the element data
import elementData from "@/data/elements.json"
export default function sitemap(): MetadataRoute.Sitemap {
// IMPORTANT: Use your production URL here!
const baseUrl = "https://www.atomicchart.com"
// Generate sitemap entries for /elements/[slug] pages
const elementsPages = elementData.elements.map((element) => ({
url: `${baseUrl}/elements/${element.name.toLowerCase()}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
// Home page - we'll update this page later
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
// Elements pages
...elementsPages
]
}
/sitemap.xml
, we can see the working sitemap!
localhost:3000/sitemap.xml
1234567891011121314151617181920212223
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.atomicchart.com</loc>
<lastmod>2024-12-04T03:45:17.614Z</lastmod>
<changefreq>daily</changefreq>
<priority>1</priority>
</url>
<url>
<loc>https://www.atomicchart.com/elements/hydrogen</loc>
<lastmod>2024-12-04T03:45:17.614Z</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.atomicchart.com/elements/helium</loc>
<lastmod>2024-12-04T03:45:17.614Z</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
...
... (+116 more elements)
...
</urlset>
src/app/page.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
import { elements } from '@/data/elements.json';
import Link from 'next/link';
// Group elements by category
const groupedElements = elements.reduce((acc, element) => {
const category = element.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(element);
return acc;
}, {} as Record<string, typeof elements>);
// Helper function to generate empty grid positions
const emptyGridPositions = Array.from({ length: 18 * 10 }, (_, index) => {
const ypos = Math.floor(index / 18) + 1;
const xpos = (index % 18) + 1;
return { ypos, xpos };
}).filter(pos =>
!elements.some(el => el.xpos === pos.xpos && el.ypos === pos.ypos)
);
export default function Home() {
return (
<>
{/* Desktop view - periodic table grid */}
<div className="hidden lg:grid grid-cols-18 gap-[2px] w-full box-border">
{elements.map((element) => (
<Link
title={element.name}
key={element.number}
href={`/elements/${element.name.toLowerCase()}`}
className="cursor-pointer relative flex flex-col justify-center items-center p-1 w-full min-w-0 aspect-square text-xs bg-white dark:bg-black border border-black dark:border-white hover:z-10 hover:scale-150 transition-transform box-border"
style={{
gridRow: element.ypos,
gridColumn: element.xpos
}}
>
<div className="font-semibold">{element.symbol}</div>
<div className="text-[8px] truncate">{element.name}</div>
</Link>
))}
{/* Add empty grid boxes */}
{emptyGridPositions.map((pos, index) => (
<div
key={`empty-${index}`}
className="border min-w-0 border-black/30 dark:border-white/20 aspect-square box-border"
style={{
gridRow: pos.ypos,
gridColumn: pos.xpos
}}
/>
))}
</div>
{/* Mobile view - categorized list */}
<div className="lg:hidden w-full">
{Object.entries(groupedElements).map(([category, categoryElements]) => (
<div key={category} className="pb-8">
<h2 className="font-bold text-lg mb-2">{category}</h2>
<div className="grid grid-cols-2 gap-2">
{categoryElements.map((element) => (
<Link
title={element.name}
key={element.number}
href={`/elements/${element.name.toLowerCase()}`}
className="flex items-center p-2 border"
>
<div className="font-semibold text-lg mr-2">{element.symbol}</div>
<div className="text-sm">{element.name}</div>
</Link>
))}
</div>
</div>
))}
</div>
</>
);
}
localhost:3000
https://vercel.com/new
sitemap.ts
file. This is important for the sitemap to work correctly!
Conclusion
That's it! We've done quite a lot, but we could still take it a lot further given more time. I'll likely update this article in the future to improve things where possible. If you have any questions, send me a DM on X / Twitter. Have a great day, and may you be blessed with bountiful traffic.