# SEO for an Express site: server-side checklist

> Server-side SEO for Express: canonical host, trailing slash, real 404, ETag and Cache-Control, compression, canonical and hreflang, sitemap.xml, robots.txt, JSON-LD, llms.txt, IndexNow.

- URL: https://pakulichev.com/en/wiki/express-seo
- Author: Pakulichev (https://pakulichev.com/)
- Language: en
- Updated: 2026-10-16
- Whole site for LLMs: https://pakulichev.com/llms.txt (index), https://pakulichev.com/llms-full.txt (full text)

Copy and headings are half of SEO. The other half lives on the server: status codes, redirects, caching headers, service files. Below is what to check on any Express 4 site. The example domain is `example.com`, and the app runs behind a proxy with `app.set("trust proxy", 1)`.

## One address per page

A search engine treats `http://example.com/page`, `https://www.example.com/page` and `https://example.com/page/` as different URLs. If all of them return 200, the page's signals are split between duplicates. The fix is a 301 to a single canonical variant.

Host and scheme are easier to normalize in nginx (see the article on [deploying with pm2 and nginx](https://pakulichev.com/en/wiki/nodejs-pm2-nginx)). Without nginx:

```js
const HOST = "example.com";

app.use((req, res, next) => {
  if (process.env.NODE_ENV === "production" &&
      (req.hostname !== HOST || req.protocol !== "https")) {
    return res.redirect(301, `https://${HOST}${req.originalUrl}`);
  }
  next();
});
```

Express routing is non-strict by default: `/about` and `/about/` hit the same handler. Strip the trailing slash with a redirect:

```js
app.use((req, res, next) => {
  if (req.path.length > 1 && req.path.endsWith("/")) {
    const path = req.path.replace(/\/+$/, "").replace(/^\/+/, "/");
    const query = req.url.slice(req.path.length);
    return res.redirect(301, path + query);
  }
  next();
});
```

Collapsing leading slashes closes an open redirect: without it, a request to `//evil.com/` becomes `Location: //evil.com`, and the browser goes to another domain.

## A real 404

A common mistake is a "Not found" page served with status 200. The search engine indexes it like a normal page, and Google flags such responses as soft 404s. The last middleware in the chain must set the status explicitly:

```js
app.use((req, res) => {
  res.status(404).render("404");
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).render("500");
});
```

For permanently removed pages, `410 Gone` states directly that the page was removed on purpose and will not return. If the whole site is temporarily down, use `503` with a `Retry-After` header.

## Caching and compression

By default Express sets a weak `ETag` on `res.send()` responses and answers `304 Not Modified` itself when `If-None-Match` matches. `express.static` also sends `Last-Modified`. A crawler that gets a 304 spends less crawl budget and reaches the remaining pages sooner.

`Cache-Control` you set yourself:

```js
// files with a hash in the name: app.3f9a1c.js, style.8e2b.css
app.use("/assets", express.static("public/assets", { maxAge: "1y", immutable: true }));

// HTML: may be cached, but revalidated against the ETag every time
app.use((req, res, next) => {
  res.set("Cache-Control", "no-cache");
  next();
});
```

`no-cache` allows storing the response but requires revalidation, so updates show up immediately and repeat requests cost one 304.

Compression speeds up loading, and speed is part of Core Web Vitals:

```js
const compression = require("compression");
app.use(compression());
```

If nginx already compresses responses (`gzip on`), skip the middleware. One place is enough.

## canonical and hreflang

`<link rel="canonical">` names the primary version of a page when `?utm_source=...` or sorting parameters are appended to the URL. On a multilingual site, every version links to all language variants, including itself:

```html
<link rel="canonical" href="https://example.com/wiki/page">
<link rel="alternate" hreflang="ru" href="https://example.com/wiki/page">
<link rel="alternate" hreflang="en" href="https://example.com/en/wiki/page">
<link rel="alternate" hreflang="x-default" href="https://example.com/wiki/page">
```

The links must be reciprocal: if the English version does not link back to the Russian one, Google ignores the pair. The canonical URL is always absolute and matches the target of your 301s.

## sitemap.xml and robots.txt

Generate the sitemap from the same source as the pages:

```js
app.get("/sitemap.xml", (req, res) => {
  const urls = pages
    .map((p) => `<url><loc>https://example.com${p.path}</loc><lastmod>${p.updated}</lastmod></url>`)
    .join("");
  res.type("application/xml").send(
    `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`
  );
});
```

`pages` is the list of published pages from your storage. `loc` holds only canonical URLs that return 200, and `lastmod` is the real modification date. Escape `&` in URLs as `&amp;`. One file is limited to 50,000 URLs.

robots.txt:

```text
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=yes
Allow: /
Disallow: /admin/
Clean-param: utm_source&utm_medium&utm_campaign

User-agent: GPTBot
User-agent: ClaudeBot
User-agent: PerplexityBot
Allow: /
Disallow: /admin/

Sitemap: https://example.com/sitemap.xml
```

`Content-Signal` comes from Cloudflare's Content Signals Policy: `search` permits use in search results, `ai-input` permits use as input for AI answers grounded in the page, `ai-train` permits model training. `Clean-param` is a Yandex directive: it merges URLs that differ only in UTM tags. One detail matters: a crawler that has its own `User-agent` group ignores the `*` group entirely, so repeat any `Disallow` there.

## JSON-LD, llms.txt and IndexNow

schema.org markup in JSON-LD tells a search engine the page type, author and dates:

```js
const ld = {
  "@context": "https://schema.org",
  "@type": "Article",
  headline: page.h1,
  datePublished: page.date,
  dateModified: page.updated,
  author: { "@type": "Person", name: page.author },
  inLanguage: "en",
};
const ldScript = `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`;
```

Replacing `<` keeps a `</script>` string inside a headline from closing the tag early. Validate with Google's Rich Results Test and validator.schema.org.

llms.txt is a format proposed in 2024 (llmstxt.org): a Markdown file at the site root with a `#` heading, a short summary in a `>` blockquote and `##` sections listing links to the key pages. AI agents and some developer tools read it.

IndexNow notifies Bing and Yandex about changed URLs right away instead of waiting for a crawl. The key (8 to 128 characters: Latin letters, digits, hyphen) is kept in `.env` and served at `/<key>.txt`:

```js
const KEY = process.env.INDEXNOW_KEY;

app.get(`/${KEY}.txt`, (req, res) => res.type("text/plain").send(KEY));

async function submitIndexNow(urlList) {
  const res = await fetch("https://api.indexnow.org/indexnow", {
    method: "POST",
    headers: { "Content-Type": "application/json; charset=utf-8" },
    body: JSON.stringify({
      host: "example.com",
      key: KEY,
      keyLocation: `https://example.com/${KEY}.txt`,
      urlList,
    }),
  });
  if (!res.ok) throw new Error(`IndexNow: HTTP ${res.status}`);
}
```

Generate the key once, for example with `node -e "console.log(require('crypto').randomUUID())"`. Responses 200 and 202 mean the request was accepted; 403 means the key file is missing or does not match; 422 means `urlList` contains URLs from another host. Call `submitIndexNow` after a page is published or changed, not for every URL on every deploy.

## FAQ

### Should canonical domain redirects live in nginx or in Express?

If nginx sits in front of the app, redirecting www and extra domains there is more reliable and cheaper: the request never reaches Node. Express middleware makes sense when there is no nginx or when the rules depend on application data.

### Does Google support IndexNow?

No. IndexNow is accepted by Bing, Yandex, Seznam, Naver and Yep. For Google, sitemap.xml and Search Console remain the tools.

### Does llms.txt affect search rankings?

Search engines do not officially use it. The file describes the site structure for language models and AI agents; it is cheap to add, but it will not bring search traffic by itself.
