historyApiFallback для SPA

При разработке одностраничных приложений (SPA) маршрутизация часто выполняется на стороне клиента. Библиотеки вроде React Router, Vue Router или Angular Router перехватывают изменение URL внутри браузера без полноценного перехода между HTML-страницами.

Пример маршрутов SPA:

/
 /catalog
 /catalog/phones
 /profile/settings

При переходе между такими адресами сервер зачастую всегда должен возвращать один и тот же файл:

index.html

Именно этот файл загружает JavaScript-приложение, после чего клиентский роутер определяет, какой компонент необходимо отрисовать.

Проблема возникает при прямом открытии вложенного маршрута:

http://localhost:8080/profile/settings

Без специальной настройки dev-сервер Webpack пытается найти физический файл:

/profile/settings

Если такого файла не существует, сервер возвращает ошибку:

404 Not Found

Параметр historyApiFallback решает эту проблему.


Принцип работы SPA-маршрутизации

Обычный многостраничный сайт

В классическом приложении каждому URL соответствует отдельный HTML-файл:

/about -> about.html
/contact -> contact.html

Сервер знает, какой файл вернуть.


Одностраничное приложение

В SPA существует единая точка входа:

index.html

Все маршруты обрабатываются JavaScript-кодом:

const routes = {
  '/': HomePage,
  '/catalog': CatalogPage,
  '/profile': ProfilePage,
};

Навигация выполняется через History API браузера:

history.pushState({}, '', '/profile');

Страница физически не перезагружается.


Почему возникает ошибка 404

При обновлении страницы браузер отправляет HTTP-запрос на сервер:

GET /profile/settings

Webpack Dev Server не знает, что это SPA-маршрут.

Он пытается:

  1. Найти файл
  2. Найти директорию
  3. Вернуть статический ресурс

Если ничего не найдено:

404 Not Found

Базовая настройка historyApiFallback

Простейшая конфигурация

module.exports = {
  devServer: {
    historyApiFallback: true,
  },
};

Теперь любой неизвестный маршрут будет перенаправлен на:

index.html

Как работает fallback-механизм

После включения параметра:

historyApiFallback: true

Webpack Dev Server изменяет поведение.

До настройки

GET /profile
-> поиск файла /profile
-> 404

После настройки

GET /profile
-> файла нет
-> возврат index.html

Затем SPA-приложение:

  1. Загружается
  2. Анализирует URL
  3. Определяет маршрут
  4. Отрисовывает нужный компонент

Пример с React Router

Установка

npm install react-router-dom

Маршруты

import { BrowserRouter, Routes, Route } from 'react-router-dom';

import Home from './pages/Home';
import Profile from './pages/Profile';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Конфигурация Webpack

module.exports = {
  devServer: {
    historyApiFallback: true,
  },
};

Поведение без fallback

localhost:8080/profile

После обновления страницы:

Cannot GET /profile

Поведение с fallback

Сервер возвращает:

index.html

React Router успешно отображает:

Profile component

Использование объекта вместо true

Параметр поддерживает расширенную конфигурацию.

module.exports = {
  devServer: {
    historyApiFallback: {},
  },
};

Объект позволяет:

  • задавать собственный HTML-файл;
  • настраивать правила rewrite;
  • отключать точки в URL;
  • управлять fallback-логикой.

Параметр index

По умолчанию используется:

index.html

Можно указать другой файл.

module.exports = {
  devServer: {
    historyApiFallback: {
      index: '/main.html',
    },
  },
};

Теперь fallback будет возвращать:

main.html

Когда используется собственный index

Подобная конфигурация применяется:

  • при нескольких SPA;
  • при кастомной структуре проекта;
  • при микрофронтендах;
  • при legacy-инфраструктуре.

Параметр rewrites

rewrites позволяет задавать разные fallback-страницы для различных маршрутов.


Синтаксис

module.exports = {
  devServer: {
    historyApiFallback: {
      rewrites: [
        {
          from: /^\/admin/,
          to: '/admin.html',
        },
      ],
    },
  },
};

Пример нескольких SPA

module.exports = {
  devServer: {
    historyApiFallback: {
      rewrites: [
        {
          from: /^\/admin/,
          to: '/admin.html',
        },
        {
          from: /^\/shop/,
          to: '/shop.html',
        },
        {
          from: /./,
          to: '/index.html',
        },
      ],
    },
  },
};

Поведение маршрутов

/admin/users
-> admin.html
/shop/cart
-> shop.html
/profile
-> index.html

Регулярные выражения в rewrites

Правило:

from: /^\/admin/

означает:

URL начинается с /admin

Более сложный пример

rewrites: [
  {
    from: /^\/docs\/.*$/,
    to: '/docs.html',
  },
];

Совпадения:

/docs
/docs/api
/docs/webpack/config

Динамическая функция to

Вместо строки можно использовать функцию.

module.exports = {
  devServer: {
    historyApiFallback: {
      rewrites: [
        {
          from: /^\/app/,
          to(context) {
            return '/app.html';
          },
        },
      ],
    },
  },
};

Объект context

Функция получает информацию о запросе.

to(context) {
  console.log(context.parsedUrl);
  console.log(context.match);
  console.log(context.request);
}

Пример условного fallback

rewrites: [
  {
    from: /^\/mobile/,
    to(context) {
      const userAgent = context.request.headers['user-agent'];

      if (userAgent.includes('Mobile')) {
        return '/mobile.html';
      }

      return '/desktop.html';
    },
  },
];

Параметр disableDotRule

По умолчанию URLs с точкой считаются запросами к файлам.

Пример:

/profile/edit.png

Webpack предполагает, что это статический ресурс.


Почему это проблема для SPA

Некоторые роутеры используют точки в маршрутах:

/users/v1.0
/docs/api.v2

Без специальной настройки fallback не сработает.


Включение disableDotRule

module.exports = {
  devServer: {
    historyApiFallback: {
      disableDotRule: true,
    },
  },
};

Поведение после включения

Теперь URL:

/docs/api.v2

будет корректно перенаправляться на:

index.html

Как historyApiFallback взаимодействует со статикой

Предположим:

devServer: {
  static: './public',
  historyApiFallback: true,
}

Структура:

public/
  logo.png
  fonts/

Запрос изображения

GET /logo.png

Webpack:

  1. Находит файл
  2. Отдаёт изображение
  3. Fallback не используется

Запрос SPA-маршрута

GET /profile

Webpack:

  1. Файл не найден
  2. Активируется fallback
  3. Возвращается index.html

Важность порядка обработки

Алгоритм dev-server:

  1. Проверка статических файлов
  2. Проверка middleware
  3. Fallback
  4. Ошибка 404

Связь с HTML5 History API

Название historyApiFallback связано с:

window.history

Основные методы:

history.pushState()
history.replaceState()
history.back()

Почему fallback нужен только для Browser History

BrowserRouter

<BrowserRouter>

Использует:

/history/api

Требуется fallback.


HashRouter

<HashRouter>

URL:

/#/profile

Сервер видит только:

/

Fallback не нужен.


Сравнение BrowserRouter и HashRouter

Особенность BrowserRouter HashRouter
Красивые URL Да Нет
Требуется серверная настройка Да Нет
Использует History API Да Нет
SEO Лучше Хуже
Подходит для production Да Ограниченно

Пример полной конфигурации

const path = require('path');

module.exports = {
  mode: 'development',

  entry: './src/index.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    clean: true,
  },

  devServer: {
    port: 3000,

    static: {
      directory: path.join(__dirname, 'public'),
    },

    hot: true,

    open: true,

    historyApiFallback: {
      index: '/index.html',

      disableDotRule: true,

      rewrites: [
        {
          from: /^\/admin/,
          to: '/admin.html',
        },
        {
          from: /./,
          to: '/index.html',
        },
      ],
    },
  },
};

Поведение в production

historyApiFallback относится только к:

webpack-dev-server

В production необходимо отдельно настраивать сервер.


Пример для Nginx

location / {
    try_files $uri /index.html;
}

Пример для Apache

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /

  RewriteRule ^index\.html$ - [L]

  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d

  RewriteRule . /index.html [L]
</IfModule>

Пример для Express

app.use(express.static('dist'));

app.get('*', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'dist', 'index.html'));
});

Частые ошибки

Ошибка: Cannot GET /route

Причина:

historyApiFallback: false

или отсутствие настройки.


Ошибка после деплоя

Локально всё работает:

webpack-dev-server

Но production-сервер не настроен на SPA fallback.


Ошибка со статическими файлами

Некорректная rewrite-конфигурация может ломать ресурсы.

Плохой пример:

rewrites: [
  {
    from: /./,
    to: '/index.html',
  },
];

при неправильной структуре может перехватывать:

/app.js
/styles.css
/logo.png

Правильная стратегия

Сначала проверяются реальные файлы.

Только затем применяется fallback.


Проверка работы fallback

Тест №1

Открытие:

http://localhost:3000/profile

Ожидаемый результат:

SPA загружается корректно

Тест №2

Обновление страницы:

F5

Если конфигурация правильная:

404 не возникает

Тест №3

Прямой переход по URL

http://localhost:3000/catalog/phones

Страница должна открыться без ошибок.


Совместимость с Webpack 5

В Webpack 5 настройка выглядит так:

devServer: {
  historyApiFallback: true,
}

Поддерживается полностью.


Связь с output.publicPath

При использовании SPA важно корректно задавать:

output: {
  publicPath: '/',
}

Почему это важно

Без корректного publicPath могут ломаться:

  • lazy-loaded chunks;
  • динамические импорты;
  • ресурсы;
  • маршруты.

Пример правильной настройки

module.exports = {
  output: {
    publicPath: '/',
  },

  devServer: {
    historyApiFallback: true,
  },
};

Работа с динамическими импортами

const ProfilePage = lazy(() => import('./ProfilePage'));

Webpack загружает chunk:

/profilePage.chunk.js

При неправильной конфигурации:

  • chunk может загружаться с неверного пути;
  • fallback может маскировать проблему;
  • браузер получает HTML вместо JS-файла.

Симптом неправильной настройки

Ошибка:

Unexpected token <

Обычно означает:

вместо JavaScript-файла сервер вернул HTML

Чаще всего:

index.html

Диагностика через Network

Во вкладке DevTools:

Network -> JS file

Если Content-Type:

text/html

значит fallback сработал для JS-файла ошибочно.


Комбинирование с proxy

devServer: {
  proxy: {
    '/api': {
      target: 'http://localhost:5000',
    },
  },

  historyApiFallback: true,
}

Как работает обработка

API-запрос

/api/users

Уходит в backend.


SPA-маршрут

/profile

Попадает в fallback.


Практический сценарий

Типичное SPA:

Frontend: Webpack Dev Server
Backend: Express / Laravel / NestJS

Frontend-маршруты:

/profile
/settings
/dashboard

API:

/api/users
/api/posts

historyApiFallback должен работать только для frontend-маршрутов.


Когда fallback не нужен

Многостраничное приложение

/about.html
/contact.html

SSR-приложения

Например:

Маршруты обрабатываются сервером.


Hash-based routing

/#/profile

История браузера не используется полноценно.


Архитектурная роль historyApiFallback

Параметр фактически превращает dev-server в SPA-aware сервер.

Он:

  • понимает клиентскую маршрутизацию;
  • возвращает единый entry-point;
  • позволяет использовать HTML5 History API;
  • обеспечивает корректную работу refresh и deep linking;
  • эмулирует production-поведение SPA во время разработки.