Tutorial / 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
NextJS is a hugely popular framework, and for good reason. In addition to it's fantastic developer experience, it gives us some incredibly powerful features for Programmatic SEO. So in this article, I'll show you how you can combine all these features together to create a site with hundreds of programmatically generated pages, in a matter of minutes.
What we'll be building
This tutorial covers the technical aspects of programmatic SEO, and not so much the actual SEO strategy. Instead of making up a fictional product, as an example we'll be automatically generating pages for all 118 elements on the periodic table. This will teach you how to make progammatic SEO pages with even the simplest of datasets, and without writing any content manually (or with AI). That being said, the better your content, the better the pages will rank, so feel free to adapt the steps of this guide to your own use case as you work through it. Here's a sneak peek at what it will look:
Hit enter to accept the defaults, and then navigate into the newly created
Obviously, for your own project you will want to think carefully about what content you use to populate your pages. This should be based on your SEO strategy, and what you know your audience will be searching for (and their search intent).
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.
3. Create a dynamic route
Now that we have our content, we need to create a dynamic route, so that when we visit
Now, if we navigate to
Fantastic! This is the first of many NextJS features we'll be using to build our programmatic SEO pages, in this case, dynamic routes.
4. Fetch the page data
While our 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
This showcases the second big NextJS feature we're using -
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:
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
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).
npx create-next-app@latest atomic-chart
atomic-chart
folder (or open it in your editor of choice).
2. Prepare the page data
For this guide, I'll be using Periodic-Table-JSON, a list of every element on the periodic table and their properties.
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
]
}
../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.
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
123456789101112131415161718192021222324252627282930313233343536373839
// Import the element data
import elementData from "@/data/elements.json"
// Tell NextJS to generate a static page for each element
export const generateStaticParams = async () => {
return elementData.elements.map((element) => ({
slug: element.name.toLowerCase()
}))
}
// Force the page to be static - this will make dynamic functions return empty values
// Can remove this, but it acts as a fallback if you accidentally do something dynamic.
export const dynamic = "force-static"
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>
)
}
generateStaticParams
. This allows us to tell NextJS what pages it should statically generate. Static pages are best for SEO as search engine crawlers can see the page content without having to run any javascript. They also simply provide a fast and responsive user experience for your human audience. That being said, if you need programmatic pages with dynamic content, you can skip both the generateStaticParams
and const dynamic = "force-static"
functions. I'll likely write more about static site geneation in the future, but for now, just try your best to keep your pages static.
Test the page
Now that you've created your dynamic route and fetched the data, you can test it out by navigating to your favourite element's page, e.g. /elements/gold
. If all is working correctly, you should see the element's symbol:
localhost:3000/elements/gold

elements.json
file! And if you try to view an element that doesn't exist, you'll see the 404 page, which is a great way to handle bad URL slugs:
localhost:3000/elements/nextite


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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Import the element data
import elementData from "@/data/elements.json"
// Tell NextJS to generate a static page for each element
export const generateStaticParams = async () => {
return elementData.elements.map((element) => ({
slug: element.name.toLowerCase()
}))
}
// Force the page to be static - this will make dynamic functions return empty values
// Can remove this, but it acts as a fallback if you accidentally do something dynamic.
export const dynamic = "force-static"
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// 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
}
}
}
// Tell NextJS to generate a static page for each element
export const generateStaticParams = async () => {
return elementData.elements.map((element) => ({
slug: element.name.toLowerCase()
}))
}
// Force the page to be static - this will make dynamic functions return empty values
// Can remove this, but it acts as a fallback if you accidentally do something dynamic.
export const dynamic = "force-static"
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.