SEO для сайта на Express: что настроить на сервере
Чек-лист с короткими примерами кода для Express 4: всё, что поисковик видит до того, как дочитает текст страницы.
Тексты и заголовки – половина 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 – реальная дата изменения. Символ & в адресах экранируется как &. Лимит одного файла – 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 на позиции в поиске?
Поисковые системы официально его не используют. Файл описывает структуру сайта для языковых моделей и ИИ-агентов; сделать его дёшево, но ожидать от него роста трафика из поиска не стоит.