Как исправить ошибки Cannot GET после деплоя SPA
Исправьте ошибки Cannot GET и 404 у SPA после деплоя с помощью server rewrites для Nginx, Apache, Netlify, Vercel и S3 CloudFront.
Ошибка «Cannot GET /route» или 404 после деплоя одностраничного приложения — это, как правило, проблема конфигурации сервера, а не баг в маршрутизации. Решение состоит в том, чтобы сервер возвращал index.html для любого пути запроса, которому не соответствует реальный файл.
Сценарий знакомый. Сборка задеплоена, все страницы работают при переходе по ссылкам внутри приложения, а затем кто-то обновляет /dashboard или открывает присланную ссылку на /orders/42 — и получает пустой 404. Обычно и роутер, и сборка в порядке. У сервера запросили файл, которого не существует.
В этой статье разбирается, почему ошибка возникает только при жёстких переходах, а затем приводятся решение и конфигурации для Nginx, Apache, Netlify, Vercel и S3 за CloudFront, а также побочный эффект, который придётся учесть.
Ключевые выводы
- 404 в SPA при обновлении страницы возникает потому, что запрос доходит до сервера, который ищет реальный файл по этому пути и находит только
index.htmlв корне. - Локальные dev-серверы скрывают эту проблему, потому что уже реализуют fallback на
index.htmlдля несовпадающих путей. - Решение — это rewrite, а не redirect: нужно отдавать
index.htmlсо статусом 200, чтобы URL сохранился в исходном виде и роутер смог его прочитать. - В S3 подход с error-document сохраняет статус 404; статус 200 возвращает именно кастомный error response в CloudFront, отображающий и 403, и 404 на
/index.html. - Универсальный fallback означает, что некорректные URL возвращают 200, поэтому приложению нужен собственный wildcard-маршрут, отображающий страницу «не найдено».
Когда появляется ошибка Cannot GET?
Ошибка возникает только при жёстких переходах: обновление страницы, URL, введённый в адресную строку, или присланная глубокая ссылка, открытая в новой вкладке. Навигация внутри приложения продолжает работать, потому что после загрузки приложения роутер меняет представления целиком в браузере, не обращаясь к серверу. Точный текст сообщения зависит от хостинга: серверы на Express выводят «Cannot GET /route», а статические хостинги возвращают свою страницу 404.
Именно поэтому баг проходит через QA. Session replay свежезадеплоенного SPA показывают сбой при жёстких переходах — обновлении или открытии ссылки извне — но никогда во время кликов внутри приложения. Так что тестирование, ограниченное переходами внутри работающего приложения, проходит чисто, тогда как реальные пользователи натыкаются на 404.
Почему 404 при обновлении SPA — это проблема сервера?
Статический сервер сопоставляет каждый путь запроса с файлом на диске. Сборка SPA даёт один HTML-файл, index.html, плюс JS- и CSS-ассеты, поэтому прямой запрос к /dashboard не находит файла по этому пути, и сервер вполне корректно отвечает 404. React Router, Vue Router и SvelteKit, настроенный как одностраничное приложение, сталкиваются с этим совершенно одинаково, потому что фреймворк здесь не важен: маршруты существуют только в JavaScript, который ещё не загружен.
В локальной разработке ошибка никогда не появляется, поскольку большинство dev-серверов для SPA поставляются с уже включённым fallback: любой путь, которому не соответствует файл, автоматически получает index.html. Ваше локальное окружение незаметно делало то, чего не делает продакшен-сервер.
Как исправить ошибку Cannot GET?
Настройте сервер так, чтобы он отдавал index.html для любого пути запроса, которому не соответствует существующий файл, — тогда приложение загрузится, и его роутер отрисует представление для этого URL. Это должен быть rewrite, возвращающий index.html со статусом 200, а не redirect: редирект изменил бы URL в адресной строке, а роутеру нужен исходный путь в неизменном виде.
| Хостинг | Где находится конфигурация | Механизм |
|---|---|---|
| Nginx | блок server | try_files |
| Apache | vhost или .htaccess | FallbackResource |
| Netlify | _redirects или netlify.toml | правило rewrite со статусом 200 |
| Vercel | vercel.json | массив rewrites |
| S3 + CloudFront | конфигурация website у бакета + дистрибуция | error document + custom error response |
Если доступа к серверу действительно нет, маршрутизация на хэшах обходит всю эту проблему, поскольку фрагмент никогда не покидает браузер, но она навсегда превращает каждый URL в /#/about, так что рассматривайте её как последнее средство.
Nginx и Apache
И в Nginx, и в Apache SPA-fallback выражается одной директивой в конфигурации сервера. Для Nginx добавьте fallback через try_files в корневую локацию. Он ищет путь запроса как файл, затем как директорию, и, если не находит ни того ни другого, внутренне отдаёт /index.html со статусом 200:
server {
listen 80;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
В Apache ту же работу выполняет одна директива из mod_dir. Реальные файлы по-прежнему отдаются как есть, а всё остальное проваливается в fallback:
FallbackResource /index.html
Если приложение живёт по вложенному пути, включите его: FallbackResource /app/index.html. Старый эквивалент на mod_rewrite по-прежнему работает в .htaccess:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
Netlify и Vercel
В Netlify правило redirect со статусом 200 превращается в rewrite: браузер продолжает показывать путь, который запросил посетитель, а в ответе приходит содержимое index.html. Можно добавить однострочный файл _redirects:
/* /index.html 200
либо эквивалент в netlify.toml:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
Файл _redirects должен попасть внутрь publish-директории, поэтому убедитесь, что сборка копирует его в выходную папку; netlify.toml располагается в корне репозитория. Правило со splat не перехватывает путь, за которым стоит реальный файл, так что JS- и CSS-ассеты продолжают загружаться.
Для Vercel добавьте запись rewrites в vercel.json:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Предпочтительнее явно указывать назначение /index.html, а не /: в Vercel оба варианта разрешаются в один и тот же файл, но явная форма прямо описывает, что именно отдаётся, и переносится как мысленная модель на любой другой хостинг. Одно исключение: при установленном cleanUrls: true назначение не может содержать расширение .html, а Vercel сопоставляет index.html с корнем сайта, поэтому в качестве назначения укажите /.
S3 и CloudFront
Устранение 404 в SPA на S3 требует двух частей конфигурации, потому что одна лишь настройка бакета сохраняет статус ошибки. В статическом веб-хостинге S3 задайте index.html и как index document, и как error document:
aws s3 website s3://your-bucket \
--index-document index.html \
--error-document index.html
Это отдаёт приложение для неизвестных путей, но сохраняет статус ошибки: браузер получает index.html с кодом 404. Чтобы возвращался 200, добавьте кастомные error responses в CloudFront, отображающие и 403, и 404 на /index.html с кодом ответа 200. Отображение 403 важно, потому что дистрибуция, использующая REST-эндпоинт S3 как origin, получает 403 Access Denied, а не 404, для несуществующих ключей. В терминах Terraform:
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
Одна ловушка: кастомные error responses действуют на уровне всей дистрибуции, поэтому если вы проксируете /api/* через ту же дистрибуцию, ответы 403 и 404 от API тоже будут возвращаться как index.html.
Цена: ваши настоящие 404 исчезают
У универсального fallback есть одна цена: действительно некорректные URL теперь возвращают index.html со статусом 200 вместо настоящего 404. Сервер больше не может отличить /orders/42 от /ordersss/42, поэтому приложение должно определить собственный wildcard-маршрут, отображающий представление «не найдено». В каждом роутере для этого есть своя запись; в React Router она выглядит так:
<Route path="*" element={<NotFound />} />
Учтите, что это 404, отрисованный на клиенте: HTTP-статус по-прежнему 200, и это важно, если для вас имеет значение, как краулеры классифицируют такие страницы.
Подведём итоги
404 при обновлении — это сервер, делающий ровно то, что делают статические серверы, а решение сводится к одному правилу, записанному на диалекте вашего хостинга: перезаписывать любой путь, не соответствующий файлу, на index.html со статусом 200. Добавьте нужный сниппет для своего хостинга, задеплойте заново, проверьте жёстким обновлением глубокого маршрута, а затем добавьте wildcard-маршрут «не найдено», чтобы некорректные URL всё же сообщали пользователям, что они заблудились.
FAQ
Работает ли SPA-fallback на GitHub Pages?
Нет. GitHub Pages не поддерживает серверные rewrites, поэтому настроить правило fallback на index.html невозможно. Стандартный обходной путь — кастомная страница 404.html со скриптом, который перенаправляет на index.html, сохраняя запрошенный путь, а роутер затем восстанавливает его после загрузки. GitHub при этом всё равно отдаёт эту страницу со статусом 404. Другой вариант — маршрутизация на хэшах, при которой маршрут вообще не отправляется на сервер.
Есть ли эта проблема у фреймворков с серверным рендерингом, таких как Next.js или Nuxt?
Нет, когда они работают на собственном сервере. Фреймворк с серверным рендерингом обрабатывает каждый маршрут на сервере, поэтому обновление страницы или глубокая ссылка сразу возвращают отрендеренный HTML. Проблема 404 при обновлении затрагивает только статические одностраничные сборки, где маршруты существуют исключительно в клиентском JavaScript. Приложение, статически экспортированное из такого фреймворка, всё же может столкнуться с ней, если для запрошенного маршрута на диске нет предварительно отрендеренного HTML-файла.
Не сломает ли перезапись всех путей на index.html мои JS- и CSS-ассеты?
Нет. Каждый механизм сначала ищет реальный файл, прежде чем переходить к fallback: try_files в Nginx сперва пробует URI запроса, FallbackResource в Apache не трогает запросы к реальным файлам, а splat-rewrite в Netlify не перехватывает существующий путь, если вы не форсируете это через 200!. Если после добавления fallback ассеты всё равно не загружаются, обычная причина — относительные пути к ассетам, разрешаемые внутри вложенного маршрута: браузер запрашивает их не из той директории и получает index.html.
Вредит ли SEO отдача index.html со статусом 200?
Может вредить. Когда несуществующий URL возвращает статус 200 с содержимым «не найдено», Google может классифицировать его как «soft 404» и исключить из индекса, потому что код статуса больше не отличает реальные страницы от некорректных URL. Если поисковая индексация важна для ваших маршрутов, предварительный рендеринг или серверный рендеринг вернут корректные коды статуса для каждого маршрута. Для приложений за авторизацией краулеры никогда не видят эти маршруты, поэтому такой компромисс не имеет значения.