SPA-приложение: что это, как работает роутинг и как быть с SEO

SPA-приложение: что это, как работает роутинг и как быть с SEO Полезное

SPA (Single Page Application, одностраничное приложение) — это веб-приложение, которое браузер загружает как один HTML-документ, а дальше переключает экраны JavaScript-кодом: запрашивает данные у сервера и перерисовывает часть страницы без полной перезагрузки. Адрес при этом меняется, и у каждого экрана может быть своя ссылка.

Ниже — клиентский роутинг с рабочим примером, отличие от MPA, SSR и SSG, индексация и выбор фреймворка.

Мини-словарь: SPA, MPA, SSR, SSG

Термин Где собирается HTML Что происходит при переходе
MPA (многостраничное) на сервере, на каждый запрос браузер загружает новую страницу целиком
SPA с CSR (рендер на клиенте) в браузере, из JavaScript JS меняет часть DOM, страница не перезагружается
SSR (серверный рендер) на сервере, на каждый запрос первый экран приходит готовым HTML, дальше обычно работает как SPA
SSG (статическая генерация) заранее, при сборке готовые HTML-файлы, дальше может работать как SPA

SPA — модель навигации, а не запрет на серверный рендер: Nuxt или Next.js отдают первый экран через SSR, «оживляют» его в браузере (гидратация) и дальше работают как SPA.

Как работает SPA по шагам

  1. Сервер отдает HTML-оболочку и ссылки на JS-бандл.
  2. JavaScript выполняется, определяет текущий путь и рисует нужный экран.
  3. Пользователь кликает по ссылке — скрипт перехватывает клик, отменяет обычный переход и меняет адрес через history.pushState().
  4. Приложение запрашивает только данные (обычно JSON через fetch) и перерисовывает нужный блок.
  5. Кнопка «Назад» вызывает событие popstate — роутер снова рисует экран по текущему адресу.

Это упрощенная схема чистого CSR: фреймворки добавляют подгрузку кода частями, кеш и предзагрузку, но цикл «адрес -> роутер -> экран» тот же.

Минимальный роутер на History API

Пример без фреймворков: три экрана и «не найдено». Файл index.html:

<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Главная</title></head>
<body>
  <nav>
    <a href="/" data-link>Главная</a>
    <a href="/courses" data-link>Курсы</a>
    <a href="/courses/42" data-link>Курс 42</a>
  </nav>
  <main id="app"></main>
  <script>
    function view(path) {
      if (path === "/") return ["Главная", "Добро пожаловать"];
      if (path === "/courses") return ["Курсы", "Список курсов"];
      const m = path.match(/^\/courses\/(\d+)$/);
      if (m) return ["Курс " + m[1], "Страница курса " + m[1]];
      return ["Не найдено", "Такой страницы нет"];
    }
    function render() {
      const [title, text] = view(location.pathname);
      document.title = title;
      const h1 = document.createElement("h1");
      h1.textContent = text;
      document.getElementById("app").replaceChildren(h1);
    }
    document.addEventListener("click", (e) => {
      const a = e.target.closest("a[data-link]");
      if (!a || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return;
      e.preventDefault();
      if (a.pathname !== location.pathname) history.pushState({}, "", a.pathname);
      render();
    });
    window.addEventListener("popstate", render);
    render();
  </script>
</body>
</html>

Что увидите: при клике на «Курс 42» адрес станет /courses/42, заголовок вкладки — «Курс 42», а в Network не будет запроса нового документа. «Назад» вернет предыдущий экран, Ctrl/Cmd+клик по-прежнему открывает новую вкладку.

Ключевое: ссылки — настоящие <a href> (их видят роботы), текст идет через textContent, а не innerHTML (данные из адреса не станут разметкой — защита от XSS), document.title свой на каждом экране.

Граница примера: нужен веб-сервер с fallback, отдающий index.html на пути приложения. Через file:// pushState не сработает, а без fallback обновление на /courses/42 даст 404. Для локальной проверки подойдет, например, npx serve -s . (флаг -s включает режим одностраничного приложения).

Сопоставление адреса с экраном: логика роутера

В роутерах пути задаются шаблонами вроде /courses/:id. Логика сопоставления отдельно, запускается в Node.js:

function compile(pattern) {
  const keys = [];
  const source = pattern.replace(/:(\w+)/g, (_, key) => {
    keys.push(key);
    return "([^/]+)";
  });
  return { re: new RegExp("^" + source + "$"), keys };
}

const routes = [
  { pattern: "/", name: "home" },
  { pattern: "/courses", name: "courseList" },
  { pattern: "/courses/:id", name: "course" },
].map((r) => ({ ...r, ...compile(r.pattern) }));

function match(path) {
  for (const r of routes) {
    const m = path.match(r.re);
    if (m) {
      const params = Object.fromEntries(
        r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])
      );
      return { name: r.name, params };
    }
  }
  return { name: "notFound", params: {} };
}

console.log(match("/courses/42"));
console.log(match("/courses"));
console.log(match("/courses/42/edit"));

Вывод:

{ name: 'course', params: { id: '42' } }
{ name: 'courseList', params: {} }
{ name: 'notFound', params: {} }

Типичная ошибка — регулярное выражение без якорей ^ и $: шаблон / найдется в любом пути, и первый маршрут «съест» остальные:

const loose = new RegExp("/");
console.log(loose.test("/courses/42")); // true - любой путь станет главной
const strict = new RegExp("^/$");
console.log(strict.test("/courses/42")); // false - совпадает только сам "/"

Исправление — якоря в compile, как в рабочем варианте; без них все три адреса вернули бы home.

History API или hash-роутинг

History API (/courses/42) Hash (/#/courses/42)
Нужна настройка сервера да, fallback на index.html нет, часть после # серверу не отправляется
Вид адреса обычный с #
Индексация каждый путь — отдельный URL # поисковики обычно не считают отдельной страницей

Hash-режим уместен для внутренних панелей и демо без доступа к серверу, для публичных страниц берут History API.

SPA и SEO: что происходит с индексацией

Google выполняет JavaScript: робот сначала сканирует HTML, а рендер ставится в очередь и может пройти позже. Яндекс тоже рендерит JS, но насколько полно и быстро это происходит для конкретного сайта, зависит от его настроек и проверяется в Вебмастере. Поэтому надежнее не рассчитывать на рендер, а отдавать важный контент в исходном HTML.

Что чаще всего ломает индексацию SPA:
— контент и ссылки появляются только после клика или скролла — робот их, скорее всего, не увидит;
— навигация на <div onclick> вместо <a href> — роботу нечего сканировать;
— один <title> и description на все экраны;
— soft 404: fallback отдает index.html с кодом 200 на любой путь, и несуществующие адреса выглядят для поиска как настоящие страницы. Лечится списком маршрутов на сервере (неизвестный путь — код 404) или SSR/SSG;
— долгий первый экран из-за большого бандла.

Практическое правило: для каталогов, статей и лендингов — SSR или SSG (Nuxt, Next.js, SvelteKit, Angular SSR), для личного кабинета и админки за логином — обычный CSR, индексировать там нечего.

Плюсы и минусы SPA — с условиями

Свойство Когда это правда Когда нет
Быстрые переходы после первой загрузки, передаются только данные если каждый экран тянет новый крупный чанк
Меньше нагрузка на сервер сервер отдает JSON вместо HTML при SSR сервер снова рендерит страницы
Работа офлайн если настроен Service Worker и кеш сама по себе SPA офлайн не работает
Первый экран с SSR/SSG — быстро чистый CSR ждет загрузки и выполнения JS

Про безопасность: SPA не «небезопасна по определению», но весь ее код публичен. Секреты в бандл не кладут, права проверяет сервер на каждом запросе, токен сессии обычно держат в cookie с HttpOnly, Secure, SameSite, а не в localStorage. Это базовые меры: нужны еще CSRF-защита, CSP и обновление зависимостей.

Фреймворки для SPA

Задача Что взять Когда иначе
Интерфейс с плавной кривой входа Vue + Vue Router, Nuxt для SSR если в команде уже React
Большая экосистема и рынок вакансий React + React Router, Next.js для SSR если нужен фреймворк «все включено»
Крупный проект со строгой структурой Angular (роутер, формы, DI из коробки) для небольшого проекта избыточен
Минимум кода в бандле Svelte + SvelteKit если важна готовая экосистема библиотек

React — библиотека интерфейса: роутинг и SSR добавляют пакеты и фреймворки поверх; у Vue и Angular роутер официальный.

Если не получилось

  • Обновление страницы дает 404 — на сервере нет fallback на index.html для путей приложения.
  • «Назад» не меняет экран — нет обработчика popstate.
  • Пустая страница и ошибка в консоли — относительные пути к скриптам (src="app.js") на вложенном адресе ищутся не там; нужен абсолютный путь /app.js.
  • Страницы не попадают в поиск — проверьте, что видит робот (проверка URL в Search Console, проверка ответа сервера в Вебмастере), и переведите публичные страницы на SSR/SSG.

Выводы

  • SPA загружает один HTML-документ, а дальше меняет экраны JavaScript-кодом и адрес через History API.
  • SPA — про навигацию; SSR и SSG — про то, где собирается HTML, и их можно совмещать с SPA.
  • Роутеру нужны настоящие ссылки, обработка popstate, свой title на экран и fallback на сервере.
  • Для страниц из поиска важный контент надежнее отдавать в HTML через SSR или SSG, а неизвестным путям — код 404.
  • Чистый CSR хорош для интерфейсов за логином, где индексация не нужна.

Где применяется / связь с практикой

Освойте тему на практике

SPA — основа личных кабинетов, админок и дашбордов. Если хочется собрать такое приложение на Vue 3 с роутингом, состоянием и серверным рендером в Nuxt, посмотрите курс по Vue.js. Разобрать отдельные темы фронтенда с практиками можно на бесплатных открытых уроках Otus.

FAQ

Можно ли сделать SPA без фреймворка?
Да, как в примере выше, но с ростом приложения придется самому писать вложенные маршруты, загрузку данных, кеш и управление состоянием.

PWA и SPA — одно и то же?
Нет. PWA — набор возможностей (манифест, Service Worker, установка, офлайн), который можно добавить и к SPA, и к многостраничному сайту.

Чем SPA отличается от мобильного приложения на React Native?
SPA рендерит DOM в браузере, React Native — нативные компоненты платформы; общие у них язык и компонентный подход.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)