By Nabin Ghimire, Flutter Developer & Computer Engineer in Kathmandu, Nepal
Published
Updated
11 min read
Next.js gives you good SEO defaults and then leaves the hard parts to you. The App Router handles streaming, prefetching and route metadata, but it will happily let you ship duplicate titles, three canonicals or a sitemap that lists a page you redirected away last week. This is the checklist I run before a build goes live, in the order I run it.
1. Exactly one canonical per page
Set metadataBase once in the root layout, then give every page an explicit canonical through alternates. Dynamic routes are where this goes wrong: a filtered or paginated URL that renders the same content needs to point at the canonical version, and a fallback that returns a 200 with an empty shell needs to return a 404 instead.
export const metadata = {
metadataBase: new URL("https://example.com"),
alternates: { canonical: "/projects" },
};Two more canonical rules that catch real problems: the canonical must match the host you redirect to (if the apex 308s to www, then www is the canonical), and trailing slash behaviour must be consistent or the same page will be indexed twice.
2. Titles and descriptions are a budget, not a vibe
Titles get about 60 characters before they are truncated in a search result, and descriptions get roughly 140 to 155. I enforce both with a script rather than a spreadsheet, because copy changes after the script exists and a human reviewer does not. The script fails the build, which is what makes the rule real. The other half of this is uniqueness. If two pages can be swapped without anyone noticing, they are competing with each other. Project and blog templates are the usual offenders, because the title comes from the record rather than from a decision.
3. Structured data is a graph, not decoration
A Person block on the home page that nothing links to is worth very little. What matters is that entities reference each other by @id, so a search engine or an AI assistant can resolve "who is this person" across the site: the Person, the Organization they cofounded, the WebSite, and the ProfilePage that carries the Person as its main entity.
{ "@type": "Person", "@id": "https://example.com/#person", "name": "..." }
{ "@type": "Organization", "founder": { "@id": "https://example.com/#person" } }Then validate it. Parse every block, check required fields, and confirm every @id reference resolves to something the page actually defines. A broken reference is worse than no schema, because it contradicts itself.
4. Generate the plumbing, never hand-write it
Hand-maintained sitemaps rot. In the App Router you can generate one from the same data that generates the routes, which means a new project or post appears in the sitemap with no extra step and no chance of a stale URL surviving a rename.
export default function sitemap(): MetadataRoute.Sitemap {
return ROUTES.map((route) => ({
url: `${SITE_URL}${route.path}`,
lastModified: new Date(route.updatedAt),
}));
}The same applies to robots.ts. Allow the crawlers you actually want, including the AI agents: Googlebot and Bingbot for search, GPTBot and OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended and Applebot for the assistants that increasingly answer "who is this person" questions. Blocking them does not protect content that is already public; it just removes you from the answer. One rule that saves a lot of pain: never list a URL in the sitemap that you also exclude with noindex. Conflicting signals make crawlers distrust the whole file, and the exclusion is what wins.
5. Redirects must be permanent and single hop
Every redirect chain costs a request and leaks a little authority. Apex to www, http to https and legacy locale prefixes should all be 301 or 308, defined in config rather than in middleware, and each should land directly on the final URL. Then verify with curl -I on all four variants, because a redirect that works in the browser can still be two hops.
curl -I https://example.com
curl -I http://www.example.com
curl -I https://example.com/old-path6. Content must be readable with JavaScript disabled
This is the check that catches the most damage in an animated portfolio. If the copy is injected by an animation library, or if a heading only exists after a client component hydrates, then the page is empty to anything reading the HTML. My rule is simple: server render the text, and let the animation start from that state. curl the HTML and confirm the words are present. The same rule applies to animated headings. Split-text animations that render words as separate spans without real whitespace between them produce headings like "Ibuildinterfacesthatmovepeople" in the extracted text. Keep the whitespace, put the full sentence in an aria-label, and hide the decorative spans.
7. Core Web Vitals is a build gate, not a report
LCP under 2.5s, CLS under 0.1 and INP under 200ms are the targets, and the way to hit them is to make them checks rather than dashboards. The three that actually move the numbers on a Next.js marketing site:
- Reserve space for anything that loads late, with an explicit aspect ratio, so CLS stays near zero.
- Keep the LCP element as text, not an image and never a canvas, and mark only that element
priority. - Load heavy libraries (3D, animation) behind a dynamic import with
ssr: falseand mount them only when the container is in view.
8. Images at the size they are displayed
A portrait that never renders wider than 420 CSS pixels should not be requested at 3840. Check the network panel for the actual requested widths, cut the deviceSizes list in the config down to sizes you genuinely use, and set sizes on every next/image so the browser picks sensibly.
9. The page has to answer the question
None of the above matters if the page is 200 words. For a personal site, 800 words of real content on the home and about pages is a low bar that most portfolios miss entirely. For a case study, 400 words with a problem, your role, the process and an honest result beats a screenshot gallery. And the number one rule: do not publish a metric you cannot show. An invented "60% faster" costs you the reader's trust the first time someone asks how it was measured.
The checklist, in order
- One canonical per page, matching the host you redirect to.
- Titles under 60 characters, descriptions at 140 to 155, all unique, enforced by a script.
- Structured data as one connected graph, validated in the build.
- Generated sitemap and robots, no URL that contradicts a noindex.
- Permanent single hop redirects, verified with
curl -I. - Every important page readable with JavaScript disabled.
- LCP, CLS and INP treated as build gates.
- Images requested at the size they render.
- Enough words to deserve the ranking, and no invented numbers.
Run this list before launch, not after indexing. The order matters: fix the HTML and the metadata first, because Core Web Vitals work on a page whose content never renders is wasted effort, and structured data on a page with a duplicate title is a wasted afternoon. Two final habits that keep this honest over time. Keep a script that fails the build on the copy rules, so titles and descriptions cannot quietly regress after someone edits a page in a hurry. And re-run curl -I on the redirect set whenever a host or a route changes, because redirect chains reappear the moment a legacy path comes back from the dead.
