SEO for an Express site: server-side checklist

A checklist with short Express 4 code samples: everything a search engine sees before it reads the page text.

Author: Published: Updated: 4 min read

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). Without nginx:

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:

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:

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:

// 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:

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:

<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:

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:

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:

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:

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.

Frequently asked questions

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.