Next.js SEO Notes: Getting Your Site Discovered by Search Engines
Practical Next.js SEO notes: favicon, metadata, self-hosted fonts, SSG and ISR, sitemap.xml, robots.txt and Google Search Console.
Last updated: •3 min read
favicon
- Replace
favicon.icoin theappdirectory - You can use Real Favicon Generator to produce the
.icofile
opengraph-image
- Add the image that is shown when your site is shared on social media to the
appdirectory - You can create the image with GIMP
Metadata
- Next.js has detailed support for configuring metadata
- Use the browser developer tools to check that the
<head>contains the right meta tags - You can test with a social share preview tool (the site must be deployed publicly)
- You can also expose your local machine to the internet over SSH:
https://docs.srv.us/
Self-hosted Fonts
- Loading Google Fonts from its CDN sends user information (IP address, User-Agent, language, and so on) to Google
- Under privacy laws such as the EU's GDPR this may not be compliant
- Self-hosting the fonts is recommended. The benefits:
- Better privacy compliance
- Faster loading
Self-hosting fonts in Next.js:
// layout.js
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<Header />
<main className="p-5">{children}</main>
<Footer />
</body>
</html>
);
}
Dynamic Metadata
generateMetadatalets you generate metadata dynamically from your data- It is a Server Function, so it cannot live on the same page as
"use client"
e.g.:
export async function generateMetadata({
params: { postId },
}: BlogPostPageProps): Promise<Metadata> {
const response = await fetch(`https://dummyjson.com/posts/${postId}`);
const post: BlogPost = await response.json();
return {
title: post.title,
description: post.body,
// openGraph: {
// images: [
// {
// url: post.imageUrl
// }
// ]
// }
};
}
generateMetadata is a server function, so you cannot declare "use client" on that page. If the page needs interactivity, state management or other client-side behaviour, wrap that part in a separate component and import it.
Fetch Request Deduplication
Often the page component needs the same API data as the metadata. If the server fetches the same API twice with the same result, isn't that wasted waiting time?
e.g.:
export async function generateMetadata({
params: { postId },
}: BlogPostPageProps): Promise<Metadata> {
const response = await fetch(`https://dummyjson.com/posts/${postId}`);
const post: BlogPost = await response.json();
return ...
}
export default async function BlogPostPage({
params: { postId },
}: BlogPostPageProps) {
const response = await fetch(`https://dummyjson.com/posts/${postId}`);
const { title, body }: BlogPost = await response.json();
- Next.js automatically deduplicates identical
fetchrequests - This only applies to
fetch, not to axios, Prisma and so on - If you don't use
fetch, you can deduplicate manually withcache()
Example:
import { cache } from "react"
// Manually deduplicate requests if not using fetch
const getPost = cache(async (postId: string) => {
const post = await prisma.post.findUnique(postId);
return post;
})
export async function generateMetadata({
params: { postId },
}: BlogPostPageProps): Promise<Metadata> {
const response = await getPost()
return ...
}
export default async function BlogPostPage({
params: { postId },
}: BlogPostPageProps) {
const response = await getPost();
const { title, body }: BlogPost = await response.json();
SSG (Static Site Generation)
- With the approach above, waiting for
fetchon every request hurts SEO - Use SSG to build the data ahead of time and cache it on the server, improving performance and your SEO score
- Use
generateStaticParams()to define static pages at build time
e.g.:
export async function generateStaticParams() {
const response = await fetch("https://dummyjson.com/posts");
const { posts }: BlogPostsResponse = await response.json();
return posts.map(({ id }) => id);
}
This function runs only once, at build time, so:
- You don't need to worry much about optimising what's inside it. However long it takes, users never feel it; it only affects build time.
- The data you fetch in it should not change often, for example blog posts, product pages or landing pages. Otherwise you have to rebuild by hand each time the data changes to refresh the cache.
Besides rebuilding, you can set a revalidate time that tells the Next server to refresh the page data periodically. This is called ISR (Incremental Static Regeneration).
// app/posts/[id]/page.tsx
export const revalidate = 60;
Also, for a route whose ID is not in the returned list (say you return [{id:1}] and a request comes in for /posts/2), the page is built on demand like SSR, sent to the user, and then cached.
This is useful when you have hundreds or thousands of pages and don't want to fetch all of them at build time (traffic spikes, resource cost): pages are cached as visitors browse them.
There is also a setting that returns a 404 page whenever someone navigates to a route that is not in the returned list.
export const dynamic = 'force-static' // force caching
Sitemap
A sitemap lets search engines see every page of your site, in case some pages can't be reached through other pages and would otherwise be missed.
Normally you shouldn't make that mistake, but having a sitemap certainly helps SEO, so just set one up. It's only a few lines of code.
It usually lives at baseurl/sitemap.xml and looks like this:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://myawesomeblog.com/about</loc>
<lastmod>2025-06-11T07:07:09.092Z</lastmod>
</url>
<url>
<loc>https://myawesomeblog.com/posts/1</loc>
<lastmod>2025-06-11T07:07:09.092Z</lastmod>
<changefreq>daily</changefreq>
</url>
...
</url>
Because the pages in a sitemap change often and there can be a lot of them, you'll want to generate it dynamically, and Next.js provides a way to do that:
// sitemap.ts
import { BlogPostsResponse } from "@/models/BlogPost";
import { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const response = await fetch("https://dummyjson.com/posts");
const { posts }: BlogPostsResponse = await response.json();
const postEntries: MetadataRoute.Sitemap = posts.map(({ id }) => ({
url: `${process.env.NEXT_PUBLIC_BASE_URL}/posts/${id}`,
lastModified: new Date(),
changeFrequency: "daily",
// priority:
}));
return [
{
url: `${process.env.NEXT_PUBLIC_BASE_URL}/about`,
lastModified: new Date(),
},
...postEntries,
];
}
priority is a numeric weight, but few people set it.
If you set lastModified to the current time with new Date() everywhere, search engines notice and treat it as if you hadn't set it at all. You can't fool them. 😄
robots.txt
Tells search engines a few things: which crawlers you are addressing, which pages may be crawled, and so on.
e.g.:
// robots.ts
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/privacy"],
},
],
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
};
}
Google Search Console
Google Search Console lets you register your deployed site to see whether Google has indexed it. It also analyses your traffic and shows which keywords people used to find you.
The catch: you need to buy a domain first. 😢
I'll write a domain-purchase tutorial later.