SEO for Solos
Chapter 09Free

Entity gating

The single control that stops a dynamic route from generating an unbounded number of thin pages. Gate against an authoritative list, 404 everything else, and treat the list as data.

4 min read · updated 2026-08-13· last reviewed 2026-07-26

An ungated dynamic route is an unbounded page generator

The principle

A route like /glossary/[term] that renders whatever slug it is given will render a page for every slug anyone requests. That includes slugs a crawler invented by mangling a URL, slugs from a broken internal link, and slugs an adversary generated deliberately.

Each of those is an indexable page with no content. A few hundred of them will change how the site is assessed, and you will not find them by looking at your own sitemap, because they were never in it.

The fix is one line, and it is the highest-impact single line in the programmatic chapters: gate the route against an authoritative list, and call the framework's not-found handler for anything else.

The implementation

typescript
// kit/pseo/gating.ts
export function createEntityGate<T extends { slug: string }>(entities: readonly T[]) {
  const bySlug = new Map(entities.map((entity) => [entity.slug, entity]));
  return {
    /** All valid slugs, for static generation. */
    slugs: () => entities.map((entity) => entity.slug),
    /** Returns undefined for anything not on the list. Never a partial match. */
    resolve: (slug: string): T | undefined => bySlug.get(slug),
    size: entities.length,
  };
}
tsx
const gate = createEntityGate(GLOSSARY_TERMS);

export function generateStaticParams() {
  return gate.slugs().map((term) => ({ term }));
}

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

The authoritative list is data, not a database query

The principle

The list of valid entities should be a committed TypeScript module, not a runtime query. Three reasons, in order of how much they will cost you:

  1. A build that queries a database is not reproducible, and it fails when the database is down. That turns a database outage into a deploy outage.
  2. A change to the entity set becomes a reviewable diff rather than an invisible data change.
  3. The gate has to be synchronous to be usable in generateStaticParams without an await chain through every caller.

The implementation

The generation script queries the source, emits typed TypeScript, and stamps the time:

typescript
// kit/pseo/generate.ts
const rows = await source.query();
const output = [
  `// Generated by scripts/generate-entities.ts. Do not edit by hand.`,
  `export const ENTITIES_UPDATED_AT = ${JSON.stringify(new Date().toISOString())};`,
  `export const ENTITIES = ${JSON.stringify(rows, null, 2)} as const;`,
].join("\n\n");
await writeFile(target, output);

The *_UPDATED_AT constant is not decoration. It is the lastModified value for every URL in the tier, which is how the sitemap gets a real date instead of a build stamp.

Do not accept partial or fuzzy matches

The principle

It is tempting to be forgiving: strip a trailing s, try a case-insensitive match, fall back to the closest string. Every one of those turns one canonical URL into several, all returning 200, all with the same content.

Be strict, and handle the forgiving cases as explicit redirects from a known alias list. An alias is data you control; a fuzzy match is a rule that generates URLs you have never seen.

The implementation

typescript
export function createEntityGate<T extends { slug: string }>(
  entities: readonly T[],
  aliases: Readonly<Record<string, string>> = {},
) {
  const bySlug = new Map(entities.map((entity) => [entity.slug, entity]));
  return {
    resolve: (slug: string) => bySlug.get(slug),
    /** Returns the canonical slug to redirect to, or undefined. */
    resolveAlias: (slug: string) => {
      const target = aliases[slug];
      return target && bySlug.has(target) ? target : undefined;
    },
  };
}

A resolved alias should return a 301 to the canonical slug, not render the page. Rendering it creates the duplicate you were trying to avoid.

Checks this chapter covers

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

Product analytics are optional. On the skill page, X advertising measurement is also optional and shares your visit and ad identifiers with X. Both are off until you allow them. Privacy policy.