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

Чек-лист с короткими примерами кода для Express 4: всё, что поисковик видит до того, как дочитает текст страницы.

Автор: Опубликовано: Обновлено: 3 мин чтения

Тексты и заголовки – половина 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). Если 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 по умолчанию работает в нестрогом режиме маршрутизации: /about и /about/ попадают в один обработчик. Слеш в конце нужно убирать редиректом:

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 в цепочке должен выставлять статус явно:

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 нужно задать самостоятельно:

// файлы с хешем в имени: 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:

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

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

canonical и hreflang

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

<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

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

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:

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 помогает поисковику понять тип страницы, автора и даты:

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:

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 подряд.

Частые вопросы

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

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

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

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

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

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