RadarMakers 分享小型 SaaS 實際使用的 Next.js SEO 檢查清單
The Next.js SEO checklist we actually use on a small SaaS
RadarMakers 分享其小型 SaaS 使用的 Next.js SEO 檢查清單,整理實際修復的故障及採用的防護措施。清單涵蓋伺服器端內容呈現、爬蟲 metadata、Open Graph 圖片、網站地圖分頁、noindex、資料頁索引條件、永久重新導向及結構化資料,並附驗證方法。網站地圖須在請求時生成並逐頁讀取 API;分類頁的索引門檻設為至少 3 個上線產品。
Most Next.js SEO checklists tell you to "add metadata" and "create a sitemap". That is true and not very useful. The things that actually hurt our small site were quieter: a sitemap that silently held 20 products, a homepage indexed as "Loading...", link previews without an image on most pages.
This is the checklist we run on RadarMakers, a launch platform for indie makers built with the Next.js 16 App Router and a Go API. These checks combine bugs we fixed with safeguards we now use, each with the code and a concrete way to verify it.
1. Render the real content on the server
Google indexed our homepage as "Loading...". The whole view sat inside a Suspense boundary and fetched its feed in the browser, so the HTML a crawler received held the page's loading fallback instead of its product feed.
The fix was to fetch the first page of the feed on the server and render it into the initial HTML, so the products, titles and links are there before any JavaScript runs.
Check: curl -s https://yoursite.com | grep "a product name you expect" is a quick first test, but the text can also appear inside serialized data in a script tag. Confirm it properly: open the page with JavaScript disabled and make sure the products and links are real elements, then use URL Inspection in Search Console to see what Google rendered.
2. Decide where metadata goes for the bots you care about
Since Next.js 15.2, metadata can be streamed: when generateMetadata is slow, the page starts sending HTML and the title, description and canonical arrive later in the body. Next.js keeps a list of "HTML limited" bots that get blocking metadata in the head instead. Googlebot is deliberately left off that list, because it runs JavaScript and the Next.js docs say it can interpret streamed metadata.
After the "Loading..." incident we chose to be defensive and put Googlebot on the list as well. Setting htmlLimitedBots replaces the default pattern rather than extending it, so we copied the default list and added Googlebot to it:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
htmlLimitedBots:
/Googlebot|[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i,
};
export default nextConfig;
The tradeoff is that these bots wait for metadata before the first byte, and we accept the potential increase in response time.
Check: do not just grep for a title anywhere in the response. A quick check is to cut out the head and look for the three tags there:
curl -s -A "Googlebot" https://yoursite.com/some-page \
| python3 -c "import sys,re; h=re.search(r'<head.*?</head>', sys.stdin.read(), re.S).group(0); print('<title>' in h, 'name=\"description\"' in h, 'rel=\"canonical\"' in h)"
This is a string search, not a parser, so treat three True values as a hint and then open the page source as Googlebot sees it and read the head yourself.
3. Give every page an Open Graph image, not only the root
Next.js metadata merges shallowly. If a layout defines openGraph.images and a page defines its own openGraph object without images, the page's object replaces the layout's, and the image is gone. Every subpage on our site had its own openGraph block, so only the homepage, product pages and profiles showed a picture when shared on Slack or LinkedIn. Everything else was a bare link.
We now keep the default image in one constant and pass it to the openGraph blocks that do not have a better one. One gap remains: a profile without an avatar still overrides the root block without an image, a gap we have not closed yet. Three excerpts from separate files:
// lib/seo.ts
export const OG_IMAGES = [
{ url: "/radarmakers-og.png", width: 1200, height: 630, alt: "RadarMakers" },
];
// app/layout.tsx, excerpt: relative image paths become absolute URLs
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://radarmakers.com"),
};
// app/example/page.tsx, an example page
import type { Metadata } from "next";
import { OG_IMAGES } from "@/lib/seo";
export const metadata: Metadata = {
title: "Example page",
openGraph: { title: "Example page", images: OG_IMAGES },
};
Check: paste a few deep links, not only the homepage, into a link preview tool or a private Slack channel.
4. Build the sitemap at request time and page through the API
Our sitemap had two separate bugs, and neither one threw an error.
First, it was generated during the Docker build. The API hostname does not resolve inside the build container, so every deploy published a sitemap with the static pages and not a single product. Making the route dynamic fixed it:
// app/sitemap.ts
export const dynamic = "force-dynamic";
Second, the sitemap asked the API for 1000 products. The endpoint has a ceiling of 50, and a request above the ceiling does not return 50, it falls back to the default page of 20. So the sitemap quietly listed 20 products. Now it reads one page at a time:
const MAX_PAGES = 50;
async function allPages<T>(page: (offset: number) => Promise<T[] | null>, perPage: number): Promise<T[]> {
const rows: T[] = [];
for (let i = 0; i < MAX_PAGES; i++) {
const batch = await page(i * perPage).catch(() => null);
if (batch == null) break;
rows.push(...batch);
if (batch.length < perPage) break;
}
return rows;
}
Two decisions are built into that loop. MAX_PAGES is a safeguard against an endpoint that keeps answering with full pages forever; with 50 products per page it caps the map at 2,500 products, so it is a number to watch as the catalog grows. And a page that fails ends the walk and keeps what came before it. That is an availability tradeoff we chose: a map missing a few rows, rather than a 500 for the whole file.
Check: count the URLs in your live sitemap and compare them with the pages that should be indexed, meaning canonical pages that are not redirects and not marked noindex. Remember that a sitemap helps discovery; it does not guarantee indexing.
5. Do not block in robots.txt what you want removed from the index
Our robots.txt disallowed /auth/. A disallowed URL can still be indexed from links, and a crawler blocked from fetching the page never sees a noindex on it, so addresses such as /auth/callback?error=... could stay in results.
We removed /auth/ from the disallow list and send a noindex header for those paths instead, as part of the same config object:
// next.config.ts, inside nextConfig
async headers() {
return [{ source: "/auth/:path*", headers: [{ key: "X-Robots-Tag", value: "noindex" }] }];
},
Keep Disallow for things that are useless to crawl, such as the admin panel or API routes, and use noindex for pages that must not appear in results.
Check: curl -sI https://yoursite.com/auth/callback | grep -i x-robots-tag.
6. Decide when generated pages are worth indexing
Category and tag pages are generated from data. We chose three live products as our minimum for indexing a category:
export const MIN_INDEXED_PRODUCTS = 3;
export function categoryIndexable(liveCount: number): boolean {
return liveCount >= MIN_INDEXED_PRODUCTS;
}
The same rule decides whether the page goes into the sitemap, so the sitemap does not advertise a page that asks not to be indexed. There is one deliberate exception, covered by a test: if the category itself loads but the product count request fails, the page stays indexable, so a short API hiccup does not flip real pages to noindex. If nothing loads, the page is noindex.
Check: for every page type generated from data, write down the condition under which it is worth indexing and what happens when the data is unavailable.
7. Redirect renamed URLs permanently
When we renamed several category slugs, the old addresses already had some search visibility. A permanent redirect from each old slug to the new one helps consolidate signals at the replacement URL. Our renames live in one mapping next to the categories, and the redirects for both the category page and its "best of" page are generated from it:
// next.config.ts, inside redirects()
...Object.entries(RENAMED_CATEGORY_SLUGS).flatMap(([from, to]) => [
{ source: `/categories/${from}`, destination: `/categories/${to}`, statusCode: 301 as const },
{ source: `/best/${from}`, destination: `/best/${to}`, statusCode: 301 as const },
]),
The redirects share one rename mapping, and a test checks that mapping against the database migration that renamed the slugs. Both 301 and 308 are valid permanent redirects.
Redirect removed pages when a relevant replacement exists, otherwise return 404 or 410. For tools we retired, we judged the tools hub, which lists the remaining tools, to be that replacement. Sending everything to an unrelated homepage may be treated as a soft 404.
Check: before renaming anything, grep for the old path in your sitemap and internal links, then add the redirect in the same commit.
8. Say what your site is in structured data
Search engines and AI overviews mixed our site up with an unrelated engineering company with a similar name. We added Organization and WebSite JSON-LD with stable @id values, a plain description and a disambiguatingDescription that says what we are not, as an intended disambiguation signal. We left out sameAs on purpose, because there are no verified profiles to point at yet, and an invented one would be worse than none.
An excerpt of the graph, trimmed to the fields that matter here:
// lib/site-schema.ts, excerpt
const SITE_URL = "https://radarmakers.com";
export const SITE_JSON_LD = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "RadarMakers",
url: SITE_URL,
description: DESCRIPTION,
disambiguatingDescription: DISAMBIGUATION,
},
{
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
name: "RadarMakers",
url: SITE_URL,
publisher: { "@id": `${SITE_URL}/#organization` },
},
],
};
// JSON inside a script tag must escape "<", or a "</script>" in the data would close the tag.
export const SITE_JSON_LD_HTML = JSON.stringify(SITE_JSON_LD).replace(/</g, "\\u003c");
DESCRIPTION and DISAMBIGUATION are plain strings defined above it in the same file.
Check: paste a page into the Schema Markup Validator and read the result as a stranger would.
The short version
- Real content in the server HTML.
- A deliberate choice about where metadata goes for each bot.
- An Open Graph image on every page type, and a list of the gaps.
- A sitemap built at request time that pages through the API.
- noindex instead of Disallow for pages that must leave the index.
- A written rule for when generated pages are indexed.
- Permanent redirects for every renamed URL.
- Structured data that says plainly what the site is.
None of this is exotic, and each item now has a concrete verification step we can run or repeat. If your Next.js site has a quiet SEO bug like these, I would like to hear about it in the comments.
來源:DEV Community · dev.to

