# SEO для сайта на Express: что настроить на сервере

> SEO для Express на сервере: 301 на основной хост, слеш в URL, честный 404, ETag и Cache-Control, сжатие, canonical и hreflang, sitemap.xml, robots.txt, JSON-LD, llms.txt и IndexNow.

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

Тексты и заголовки – половина SEO. Вторая половина живёт на сервере: коды ответов, редиректы, заголовки кеширования, служебные файлы. Ниже то, что стоит проверить на любом сайте на Express 4. Домен в примерах – `example.com`, приложение работает за прокси с `app.set("trust proxy", 1)`.

## Один адрес на одну страницу

Поисковик считает `http://example.com/page`, `https://www.example.com/page` и `https://example.com/page/` разными URL. Если все они отдают 200, вес страницы делится между дублями. Решение – 301 на единственный канонический вариант.

Хост и протокол удобнее нормализовать в nginx (пример в статье про [деплой с pm2 и nginx](https://pakulichev.com/wiki/nodejs-pm2-nginx)). Если 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 по умолчанию работает в нестрогом режиме маршрутизации: `/about` и `/about/` попадают в один обработчик. Слеш в конце нужно убирать редиректом:

```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();
});
```

Замена ведущих слешей закрывает открытый редирект: без неё запрос `//evil.com/` превратится в `Location: //evil.com`, и браузер уйдёт на чужой домен.

## Честный 404

Частая ошибка – страница «Не найдено» с кодом 200. Поисковик индексирует её как обычную, а Google помечает такие ответы как soft 404. Последний middleware в цепочке должен выставлять статус явно:

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

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

Для навсегда удалённых страниц подходит `410 Gone`: он прямо говорит, что страница убрана намеренно и возвращаться не будет. Временная недоступность всего сайта – `503` с заголовком `Retry-After`.

## Кеширование и сжатие

Express по умолчанию ставит слабый `ETag` на ответы `res.send()` и сам отвечает `304 Not Modified`, если `If-None-Match` совпал. `express.static` дополнительно отдаёт `Last-Modified`. Робот, получивший 304, тратит меньше краулингового бюджета и быстрее обходит остальные страницы.

`Cache-Control` нужно задать самостоятельно:

```js
// файлы с хешем в имени: app.3f9a1c.js, style.8e2b.css
app.use("/assets", express.static("public/assets", { maxAge: "1y", immutable: true }));

// HTML: можно кешировать, но каждый раз сверять ETag
app.use((req, res, next) => {
  res.set("Cache-Control", "no-cache");
  next();
});
```

`no-cache` разрешает хранить ответ, но требует перепроверки, поэтому обновления видны сразу, а повторные запросы стоят один 304.

Сжатие ускоряет загрузку, а скорость входит в Core Web Vitals:

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

Если nginx уже сжимает ответы (`gzip on`), middleware не нужен. Достаточно одного места.

## canonical и hreflang

`<link rel="canonical">` указывает основную версию страницы, когда к URL добавляются `?utm_source=...` или параметры сортировки. Для многоязычного сайта каждая версия ссылается на все языковые варианты, включая себя:

```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">
```

Ссылки должны быть взаимными: если английская версия не ссылается на русскую, Google игнорирует пару. Canonical всегда абсолютный и совпадает с адресом, на который ведут 301.

## sitemap.xml и robots.txt

Карта сайта генерируется из того же источника, что и страницы:

```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` – список опубликованных страниц из вашего хранилища. В `loc` попадают только канонические URL с кодом 200, `lastmod` – реальная дата изменения. Символ `&` в адресах экранируется как `&amp;`. Лимит одного файла – 50 000 URL.

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` – строка из Content Signals Policy от Cloudflare: `search` разрешает использование в поисковой выдаче, `ai-input` – в ответах ИИ с опорой на страницу, `ai-train` – для обучения моделей. `Clean-param` понимает Яндекс: он склеит URL, отличающиеся только UTM-метками. Важная деталь: робот, для которого есть своя группа `User-agent`, правила из группы `*` не читает, поэтому `Disallow` нужно повторить.

## JSON-LD, llms.txt и IndexNow

Разметка schema.org в формате JSON-LD помогает поисковику понять тип страницы, автора и даты:

```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: "ru",
};
const ldScript = `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`;
```

Замена `<` не даёт строке `</script>` из заголовка закрыть тег раньше времени. Проверка – Rich Results Test от Google и валидатор на validator.schema.org.

llms.txt – предложенный в 2024 году формат (llmstxt.org): Markdown-файл в корне сайта с заголовком `#`, кратким описанием в цитате `>` и разделами `##` со списками ссылок на главные страницы. Его читают ИИ-агенты и некоторые инструменты разработчика.

IndexNow сообщает Bing и Яндексу об изменённых URL сразу, без ожидания обхода. Ключ (8–128 символов: латиница, цифры, дефис) хранится в `.env` и отдаётся по адресу `/<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}`);
}
```

Ключ генерируется один раз, например `node -e "console.log(require('crypto').randomUUID())"`. Ответы 200 и 202 означают, что запрос принят; 403 – файл ключа не найден или не совпадает; 422 – в `urlList` есть адреса чужого хоста. Вызывайте `submitIndexNow` после публикации или изменения страницы, а не на каждом деплое для всех URL подряд.

## FAQ

### Где делать редиректы на канонический домен: в nginx или в Express?

Если перед приложением стоит nginx, редирект с www и дополнительных доменов надёжнее и дешевле делать там: запрос не доходит до Node. Middleware в Express полезен, когда nginx нет или когда правила зависят от данных приложения.

### Поддерживает ли Google IndexNow?

Нет. IndexNow принимают Bing, Яндекс, Seznam, Naver и Yep. Для Google остаются sitemap.xml и Search Console.

### Влияет ли llms.txt на позиции в поиске?

Поисковые системы официально его не используют. Файл описывает структуру сайта для языковых моделей и ИИ-агентов; сделать его дёшево, но ожидать от него роста трафика из поиска не стоит.
