SEO for Solos
Chapter 01Free

Foundation

The base layer everything else assumes. One origin, one host, one trailing-slash policy, real status codes. Get these wrong and nothing built on top of them can work.

6 min read · updated 2026-07-26· last reviewed 2026-07-26

The site has exactly one origin

The principle

Every URL your site emits, in canonicals, in structured data, in the sitemap, in Open Graph tags, in emails, has to agree on what the site's origin is. The failure mode is not subtle and it is extremely common: a staging hostname leaks into a production canonical, and the affected pages quietly de-index because they are telling Google their real address is somewhere that returns a 404 to the public.

The fix is structural rather than diligent. Define the origin in one constant. Make every other file import it. Then add a test that fails the build if any other file contains the domain as a literal, because diligence does not survive the twentieth file.

The implementation

typescript
// kit/seo/constants.ts
export function resolveSiteUrl(fallback: string): string {
  const fromEnv =
    process.env.NEXT_PUBLIC_SITE_URL ??
    (process.env.VERCEL_PROJECT_PRODUCTION_URL
      ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
      : undefined);
  return stripTrailingSlashes(fromEnv && fromEnv.length > 0 ? fromEnv : fallback);
}

The test is the part people skip, and it is the part that makes the rule hold:

typescript
it("only lib/brand.ts hardcodes the production host", () => {
  const offenders = walk(REPO_ROOT)
    .filter((file) => readFileSync(file, "utf8").includes(HOST))
    .filter((file) => !ALLOWED.has(relative(REPO_ROOT, file)));
  expect(offenders).toEqual([]);
});

One canonical host, and a permanent redirect from the other

The principle

example.com and www.example.com are different origins. If both return 200 for the same content, you have doubled your URL count, halved the link equity per URL, and doubled the crawl budget spent covering the same pages.

Pick one. It does not matter much which, as long as it is consistent and permanent. Redirect the other with a 301, not a 302: a temporary redirect tells the crawler to keep checking the old host indefinitely.

The implementation

typescript
// next.config.ts
async redirects() {
  return [
    {
      source: "/:path*",
      has: [{ type: "host", value: APEX_HOST }],
      destination: `https://${CANONICAL_HOST}/:path*`,
      permanent: true,
    },
  ];
}

Verify both directions. A redirect rule with a matching condition that is too loose produces an infinite loop, and the symptom is a page that works in your browser because of a cached redirect and fails for everyone else.

bash
curl -sI https://example.com | grep -i '^location'
curl -s -o /dev/null -w '%{http_code}\n' https://www.example.com

HTTPS everywhere, with HSTS

The principle

Mixed content is a hard failure in modern browsers rather than a warning. One http:// script tag on an https:// page is a blocked resource, and if it is your analytics or your checkout, you will not notice until the numbers look wrong.

HSTS removes the redirect hop on every repeat visit. Without it, the first request of each session goes out over plain http and is redirectable by anything on the network path between the visitor and you.

The implementation

typescript
// kit/config/headers.ts
{
  key: "Strict-Transport-Security",
  value: `max-age=63072000; includeSubDomains; preload`,
}

preload is a commitment. Once the domain is on the browsers' preload list, removal takes months. Do not add it until you are certain every subdomain, including ones you forgot about, serves HTTPS.

One trailing-slash policy

The principle

/about and /about/ are different URLs to a crawler. If both return 200, every page on the site has a duplicate. If one redirects to the other, you have one canonical form and a cheap, permanent rule.

The choice between them is arbitrary. The consistency is not.

The implementation

Normalisation belongs inside the canonical URL function, so it applies everywhere by construction rather than by remembering:

typescript
export function canonicalUrl(path: string | undefined, siteUrl: string): string {
  const origin = stripTrailingSlashes(siteUrl);
  if (!path) return origin;
  if (/^https?:\/\//i.test(path)) return stripTrailingSlashes(path) || path;

  const queryIndex = path.search(/[?#]/);
  const rawPath = queryIndex === -1 ? path : path.slice(0, queryIndex);
  const suffix = queryIndex === -1 ? "" : path.slice(queryIndex);

  let normalised = rawPath.replace(/\/{2,}/g, "/");
  if (!normalised.startsWith("/")) normalised = `/${normalised}`;
  normalised = normalised.replace(/\/+$/, "");

  if (normalised === "" || normalised === "/") {
    return suffix ? `${origin}/${suffix}` : origin;
  }
  return `${origin}${normalised}${suffix}`;
}

The edge cases that matter in production are "", "/", "/a/", "//a" and a path with a query string attached. All five appear in real code, usually because a path was built by string concatenation somewhere upstream.

A missing page returns 404, not 200

The principle

A soft 404 is a page that says "not found" in words while returning a 200 status. Crawlers read the status, not the words. The result is an indexed page with no useful content, and enough of them will affect how the whole site is assessed.

The most common source is a dynamic route that renders a template with empty data instead of calling the framework's not-found handler.

The implementation

typescript
// app/glossary/[term]/page.tsx
import { notFound } from "next/navigation";
import { GLOSSARY_TERMS } from "@/data/glossary-terms";

export default async function TermPage({ params }: { params: Promise<{ term: string }> }) {
  const { term } = await params;
  const entry = GLOSSARY_TERMS.find((candidate) => candidate.slug === term);
  if (!entry) notFound();
  return <TermTemplate entry={entry} />;
}

This is the same mechanism as entity gating, covered in its own chapter. The difference in framing: entity gating is about not generating junk pages, and this is about correctly refusing the ones you never generated.

bash
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/this-does-not-exist

The 404 page itself should carry real recovery links, not an apology. Search, the main hub pages, and the highest-traffic entry points. A 404 that offers a way forward converts; one that offers a sad face does not.

Checks this chapter covers

Each one has a command you can run against your own site.