Разработка 18 мин чтения

Как превратить обычный сайт в PWA за выходные: пошаговый туториал с кодом

Туториал на выходные: manifest.json, Service Worker, install-button и push-уведомления для iOS и Android. Готовый код, чек-листы и публикация в RuStore.

PWAразработкаfrontendмобильные приложениятуториал

Коротко (TL;DR)

  • PWA — это обычный сайт, который умеет три вещи: устанавливаться на телефон без магазина приложений, работать без интернета и присылать push-уведомления. Технически — три файла: manifest.json, sw.js (Service Worker) и блок регистрации в HTML.
  • В 2026 PWA окончательно догнала нативку по возможностям: с iOS 16.4+ работают push, с iOS 17.4+ — установка из браузера без хака «На главный экран». Раньше Apple душил PWA — теперь нет.
  • Стоимость для бизнеса: PWA дешевле нативного приложения в 3–5 раз. Один кодбейс на iOS, Android, десктоп. Не надо платить Apple 99$ в год и проходить ревью App Store.
  • За выходные реально успеть: ~13 часов чистого времени, если сайт уже на HTTPS и есть адаптивная вёрстка. Подробный план по часам — в разделе «Чек-лист на выходные».
  • Где НЕ подходит: Bluetooth, NFC, глубокий доступ к ФС, AR/VR, фоновая геолокация без открытого таба. Везде остальное — PWA закрывает.

Что такое PWA и почему именно сейчас стоит делать

Я занимаюсь веб-разработкой с 2009 года и помню времена, когда мобильный веб был набором костылей: media-запросы только появлялись, jQuery Mobile казался прорывом, а слово «оффлайн» в браузере вызывало нервный смех. PWA (Progressive Web Apps) — это технология, которая постепенно, без шума и хайпа, накрыла этот пробел и превратила обычный сайт в полноценное приложение.

Технически PWA — это три кита. Первый — файл manifest.json, который рассказывает браузеру «я приложение, вот моё имя, иконка, цвет, как меня запускать». Второй — Service Worker, фоновый JavaScript-скрипт, который перехватывает сетевые запросы и умеет кэшировать ресурсы, отдавать оффлайн-страницу и принимать push-уведомления. Третий — HTTPS, без которого Service Worker не зарегистрируется. Всё. Никакого фреймворка не нужно, никакого специального стека.

Зачем это бизнесу. Я считал экономику для пары клиентов в 2025 году — выходит так: разработка нативного приложения под iOS и Android «под ключ» обходится в 1,5–4 млн ₽ и занимает 3–6 месяцев. PWA из существующего сайта — от 80 тыс до 400 тыс ₽ и 2–6 недель. После выхода в продакшен пользователь устанавливает приложение в один тап из браузера, получает иконку на рабочем столе, открывает её как обычное приложение (без браузерной обвязки, без адресной строки), получает push-уведомления, может пользоваться без интернета.

Когда PWA НЕ подходит. Если вам нужен Bluetooth-low-energy для медицинских устройств (Web Bluetooth есть, но в iOS пока нет), NFC для платежей (Web NFC есть только на Android Chrome), глубокий доступ к файловой системе (есть File System Access API, но опять же только Chromium), фоновая геолокация без открытого таба (нет ни на одной платформе), AR/VR с тяжёлыми сценами (WebXR работает, но просадки по перформансу). Во всех остальных кейсах — интернет-магазин, корпоративный портал, медиа, доставка еды, такси, банкинг, образование, b2b-сервисы — PWA закрывает 95% задач.

Что изменилось к 2026 году по сравнению с эпохой 2018–2020, когда я последний раз серьёзно писал про PWA. Главное: Apple перестал саботировать. С iOS 16.4 (март 2023) заработали Web Push для PWA, установленных на главный экран. С iOS 17.4 (март 2024) — нормальная установка через Safari, badging иконок, нормальная работа в standalone-режиме. До этого приходилось объяснять клиентам «на iPhone push не работает, извините, спасибо Тиму Куку». Теперь — работают, надо только корректно зарегистрировать подписку.

Что нужно перед стартом — чек-лист

Прежде чем открывать редактор, убедитесь, что выполнены пять условий. Без них PWA либо не соберётся, либо соберётся, но Lighthouse поставит 50/100 с приписками «улучшите вот это».

  • HTTPS на основном домене. Service Worker регистрируется только по https (исключение — localhost для разработки). Если у вас Let's Encrypt — отлично, проверьте, что обновляется автоматически. Если хостинг типа Beget/Reg.ru — у них есть бесплатный SSL в один клик. Если самописный сервер на VPS — поставьте Caddy, он сам получит сертификат.
  • Адаптивная вёрстка. PWA устанавливается на телефон в первую очередь. Если ваш сайт на десктопе красивый, а на мобиле едет — сначала чините responsive, потом упаковывайте в PWA. Иначе пользователь установит и удалит за минуту.
  • Lighthouse score 70+ на мобиле. Откройте Chrome DevTools, вкладка Lighthouse, выберите Mobile, прогоните. Если меньше 70 по Performance — сначала оптимизация: сожмите картинки в WebP/AVIF, выключите неиспользуемые скрипты, добавьте lazy loading.
  • Иконки. Минимум 192×192 и 512×512 в PNG. Лучше — полный набор: 96, 144, 192, 256, 384, 512, плюс отдельный maskable (с safe-zone в центре) и apple-touch-icon 180×180.
  • Доступ к корню сайта. Файл manifest.json и sw.js должны лежать в корне (или в подкаталоге, если scope ограничен). Если сайт на Tilda/Wix/Wordpress без доступа к корню — будут трудности. Tilda до сих пор не даёт нормально подключить кастомный SW, и я это видел вживую у двух клиентов.
Совет из опыта. Перед стартом сделайте бэкап и заведите отдельную ветку feat/pwa. Service Worker умеет «прилипать» к домену — если зальёте кривой sw.js, пользователи могут видеть старый кэш ещё неделю, пока браузер не пересоберёт. Лучше отлаживать на staging-домене.

Шаг 1: manifest.json — паспорт приложения

Manifest — это JSON-файл, где описано как ваш сайт должен выглядеть в роли приложения. Имя, иконки, цвет рамки, ориентация, стартовая страница. Браузер читает его и понимает «ага, это можно установить».

Кладём в корень сайта файл manifest.json. Вот рабочий пример со всеми важными полями:

{
  "name": "Чимитдоржи — IT и право",
  "short_name": "Чимитдоржи",
  "description": "Блог про IT, право и AI от практикующего эксперта",
  "lang": "ru-RU",
  "dir": "ltr",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait",
  "theme_color": "#1a1a2e",
  "background_color": "#ffffff",
  "categories": ["business", "education", "productivity"],
  "icons": [
    { "src": "/icons/icon-96.png",  "sizes": "96x96",  "type": "image/png" },
    { "src": "/icons/icon-144.png", "sizes": "144x144","type": "image/png" },
    { "src": "/icons/icon-192.png", "sizes": "192x192","type": "image/png" },
    { "src": "/icons/icon-256.png", "sizes": "256x256","type": "image/png" },
    { "src": "/icons/icon-384.png", "sizes": "384x384","type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512","type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/wide.png",   "sizes": "1280x720",
      "type": "image/png", "form_factor": "wide" },
    { "src": "/screenshots/narrow.png", "sizes": "750x1334",
      "type": "image/png", "form_factor": "narrow" }
  ],
  "shortcuts": [
    { "name": "Блог", "url": "/blog/", "icons": [
      { "src": "/icons/shortcut-blog.png", "sizes": "192x192" }
    ]}
  ]
}

Разберём поля по важности. name — полное имя, видно в окне установки. short_name — короткое, оно подписывает иконку на рабочем столе (если длинное — обрежется). start_url — куда открывать после запуска (я всегда добавляю ?source=pwa, чтобы в аналитике видеть установленных пользователей отдельно). scope — какая часть сайта считается «внутри приложения». display: "standalone" — без браузерной обвязки. theme_color подкрашивает статусбар Android. background_color — цвет splash-экрана при запуске.

Подключаем manifest в <head> каждой страницы (или хотя бы стартовой):

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Чимитдоржи">

Проверяем: открываем сайт в Chrome, F12, вкладка Application → Manifest. Если всё хорошо — увидите имя, иконки превью, нет ошибок красным. Если есть «No matching service worker detected» — это нормально, мы его добавим на следующем шаге.

Шаг 2: иконки и сплэш-скрин

Тема, на которой спотыкаются почти все. Я сам в первой PWA в 2017 году упустил maskable-иконки, и на Android-устройствах иконка приложения выглядела как у бомжа — с белой окантовкой вокруг логотипа.

Полный набор размеров, который я кладу всегда: 96, 144, 192, 256, 384, 512. Это PNG с прозрачным фоном или с фирменным цветом. Минимально допустимо — 192 и 512, но Lighthouse будет ныть про «add more sizes».

Maskable-иконка — отдельная история. Android Pie и выше использует adaptive icons: иконка обрезается под форму, которую выбрал пользователь (круг, квадрат, скруглённый квадрат). Если у вас обычная иконка с логотипом по центру — её обрежет. Maskable-иконка делается так: квадрат 512×512, логотип в центральных 80% (safe zone), вокруг — фоновый цвет на всю площадь. Помечается в manifest как "purpose": "maskable". Можно дать одну иконку и для "any", и для "maskable" через "purpose": "any maskable", но лучше две разные.

Apple touch icon — 180×180 PNG, без прозрачности (Apple любит непрозрачные углы). Apple игнорирует manifest icons для splash-экрана и использует отдельный apple-touch-icon. С iOS 17.4 это поведение начало меняться, но я всё равно кладу отдельный файл — на старых iPhone (6s, 7) ничего не сломается.

Чем генерить. Я использую RealFaviconGenerator (realfavicongenerator.net) — заливаешь SVG или PNG 512×512, он выдаёт ZIP со всеми форматами под все платформы плюс готовый кусок HTML. Для maskable отдельно — Maskable.app Editor (maskable.app/editor). Для пакетной генерации в CI — npm-пакет pwa-asset-generator.

Частая ошибка. Файлы иконок должны лежать по точно тем путям, что указаны в manifest. Если сервер возвращает 404 на /icons/icon-192.png — браузер не даст установить приложение. Проверка одной строкой: открыть DevTools → Application → Manifest, кликнуть по каждой иконке — должна открыться картинка.

Шаг 3: Service Worker — мозг PWA

Service Worker — это JavaScript-файл, который браузер запускает в отдельном фоновом потоке, без доступа к DOM, но с доступом к сетевым запросам. Он живёт даже когда таб закрыт (ну, почти — браузер может его «усыпить», но события будит). Через SW работает кэширование, оффлайн, push, фоновая синхронизация.

Минимальный рабочий sw.js, который кэширует статику и умеет отдавать оффлайн-страницу:

const CACHE = 'pwa-v1.0.3';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/style.css',
  '/script.js',
  '/offline.html',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
  '/manifest.json'
];

// Установка: кэшируем критичные файлы
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

// Активация: удаляем старые версии кэша
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

// Перехват запросов: Network First для HTML, Cache First для остального
self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return;

  // HTML — сначала сеть, при провале — кэш или offline.html
  if (req.headers.get('accept') && req.headers.get('accept').includes('text/html')) {
    event.respondWith(
      fetch(req)
        .then((res) => {
          const copy = res.clone();
          caches.open(CACHE).then((c) => c.put(req, copy));
          return res;
        })
        .catch(() => caches.match(req).then((r) => r || caches.match('/offline.html')))
    );
    return;
  }

  // Всё остальное (CSS, JS, картинки) — сначала кэш, потом сеть
  event.respondWith(
    caches.match(req).then((cached) => cached || fetch(req).then((res) => {
      const copy = res.clone();
      caches.open(CACHE).then((c) => c.put(req, copy));
      return res;
    }))
  );
});

Регистрация в HTML. Кладём такой блок в основной script.js или прямо в HTML внизу страницы:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js', { scope: '/' })
      .then((reg) => console.log('SW registered, scope:', reg.scope))
      .catch((err) => console.error('SW failed:', err));
  });
}

Стратегии кэширования — это отдельная философия. Кратко, по практике:

Стратегия Когда применять Плюсы Минусы
Cache First Иконки, шрифты, лого, версионированный CSS/JS Молниеносная отдача, экономия трафика Если файл обновился, пользователь увидит старый
Network First HTML-страницы, JSON API, всё актуальное Всегда свежий контент при наличии сети Чуть медленнее на старте
Stale While Revalidate Аватарки, RSS-фиды, не-критичные данные Быстро отдаём кэш, тихо обновляем в фоне Первый показ — устаревший
Network Only Платежи, авторизация, отправка форм Никогда не отдаём устаревшее Без интернета не работает
Cache Only Оффлайн-страница, статика приложения Гарантированный показ Не подходит для динамики

Дебаг — Chrome DevTools → Application → Service Workers. Там виден статус (installing/activated/redundant), можно нажать Update, Unregister, поставить «Offline» галочку и проверить, что сайт работает без сети. Если SW не регистрируется — смотрите Console на ошибки JS внутри файла. Самая частая причина — синтаксическая ошибка в sw.js, тогда регистрация валится с TypeError.

Шаг 4: кнопка «Установить приложение»

Браузер сам решает, когда показать пользователю предложение установить PWA. Критерии у Chrome строгие: должен быть валидный manifest, зарегистрирован SW, пользователь провёл на сайте хотя бы 30 секунд и сделал минимум одно осмысленное действие. На Android это работает автоматом — Chrome выкатывает свой banner «Установить». На десктопе — иконка плюса в адресной строке.

Но рассчитывать на встроенный banner — плохая идея. Он может не показаться, а конверсия в установку без своей кнопки — около 1–3%. Со своей кнопкой и грамотным таймингом — 8–15%. Делаем по уму: ловим событие beforeinstallprompt, прячем дефолтное приглашение, показываем свою кнопку в нужный момент.

let deferredPrompt = null;
const installBtn = document.getElementById('pwa-install-btn');

window.addEventListener('beforeinstallprompt', (e) => {
  // Браузер собрался показать своё приглашение — отменяем
  e.preventDefault();
  deferredPrompt = e;
  // Показываем свою кнопку (после первого осмысленного скролла, например)
  installBtn.hidden = false;
});

installBtn.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  installBtn.disabled = true;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log('Установка:', outcome); // 'accepted' или 'dismissed'
  // Отправляем событие в аналитику
  if (window.ym) ym(123456, 'reachGoal', 'pwa_install_' + outcome);
  deferredPrompt = null;
  installBtn.hidden = true;
});

window.addEventListener('appinstalled', () => {
  console.log('PWA установлена');
  if (window.ym) ym(123456, 'reachGoal', 'pwa_installed');
});

// Проверка: запущено уже как PWA?
if (window.matchMedia('(display-mode: standalone)').matches) {
  document.body.classList.add('is-pwa');
}

UX-нюансы. Не показывайте кнопку сразу при загрузке — это бесит. Показывайте после того, как пользователь совершил «ценное» действие: прочитал статью до конца, добавил товар в корзину, посмотрел два урока подряд. Помните: предложить установить можно бесконечно раз, но если пользователь нажал «Отмена» — давайте ему отдохнуть хотя бы 7 дней, иначе он откроет настройки и заблокирует уведомления, а потом — и сайт.

На iOS свой ад. Нет beforeinstallprompt, нет API установки. Можно только показать инструкцию: «Нажми кнопку «Поделиться» в Safari → На главный экран». Я обычно показываю отдельный модал с GIF-анимацией поделиться → на главный экран только для iOS Safari (детектится по UA).

Шаг 5: оффлайн-страница

Когда интернета нет, а пользователь открывает страницу, которую SW не успел закэшировать, надо показать что-то осмысленное вместо «нет соединения». Минималистичный шаблон offline.html, который кладём в корень:

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Нет интернета — Чимитдоржи</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { font-family: system-ui, sans-serif; padding: 2rem;
           text-align: center; background: #1a1a2e; color: #fff; }
    h1 { margin-top: 4rem; font-size: 1.8rem; }
    button { padding: .8rem 1.6rem; border: 0; border-radius: 8px;
             background: #ff7d3b; color: #fff; font-size: 1rem; cursor: pointer; }
  </style>
</head>
<body>
  <h1>Нет соединения</h1>
  <p>Похоже, интернет отвалился. Когда вернётся — нажмите кнопку.</p>
  <button onclick="location.reload()">Попробовать снова</button>
</body>
</html>

SW уже умеет её отдавать — в нашем коде в строке caches.match('/offline.html'). Только убедитесь, что путь к файлу есть в массиве STATIC_ASSETS, иначе при первой установке SW его не закэширует, и при отсутствии сети получите «не работает» внутри «не работает».

Стоит ли кэшировать API-ответы. Если у вас интернет-магазин с каталогом — да, кэшируйте каталог Stale While Revalidate. Если банк или биржа — нет, никогда, только Network Only. Если соцсеть/блог — кэшируйте список постов на 5 минут (TTL через свой собственный механизм поверх Cache API), сам контент поста — на день.

Не хочется возиться — закажу за вас

Если на выходные нет настроения копаться в Service Workers и manifest.json — могу сделать вашу PWA под ключ от 80 000 ₽. С push-уведомлениями, оффлайн-режимом, установкой на iOS и Android.

Шаг 6: push-уведомления

Самая мощная и самая сложная фича PWA. С 2026 года реально работает на всех платформах, включая iOS — но только из установленного на главный экран приложения, не из обычной Safari-вкладки.

Архитектура: пользователь даёт разрешение на уведомления → браузер генерирует уникальную подписку (endpoint + ключи) → подписка отправляется на ваш сервер → сервер хранит подписку и при наступлении события отправляет ей push через специальный сервис (FCM для Chrome/Android, APNs для Safari/iOS, Mozilla Push для Firefox). PWA это всё абстрагирует через стандарт Web Push с VAPID-ключами.

Регистрация подписки на клиенте:

const VAPID_PUBLIC = 'BNa...your-public-key...';

function urlBase64ToUint8Array(b64) {
  const padding = '='.repeat((4 - b64.length % 4) % 4);
  const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}

async function subscribePush() {
  const reg = await navigator.serviceWorker.ready;
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC)
  });

  // Отправляем на свой сервер
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(sub)
  });
  return sub;
}

Обработка в Service Worker — добавляем в sw.js:

self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : { title: 'Новое уведомление' };
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body || '',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url || '/' }
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});

Отправка с сервера (Node.js, библиотека web-push):

const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:hello@example.ru',
  process.env.VAPID_PUBLIC,
  process.env.VAPID_PRIVATE
);

async function sendToAll(subscriptions, payload) {
  for (const sub of subscriptions) {
    try {
      await webpush.sendNotification(sub, JSON.stringify(payload));
    } catch (err) {
      if (err.statusCode === 410) {
        // Подписка протухла — удаляем из БД
        await db.subscriptions.delete(sub.endpoint);
      }
    }
  }
}

VAPID-ключи генерятся командой npx web-push generate-vapid-keys один раз и хранятся в переменных окружения. Публичный — на клиенте, приватный — только на сервере.

Про Россию и FCM. Google Firebase Cloud Messaging формально не заблокирован, но работает нестабильно: задержки до минут, иногда сообщения теряются. Хорошая новость — стандарт Web Push не привязан к FCM. Когда вы вызываете pushManager.subscribe, браузер сам решает, какой push-сервис использовать. Chrome пойдёт в FCM, Safari — в APNs, Firefox — в Mozilla Push. Ваш сервер просто отправляет в endpoint, который вернул браузер. То есть архитектурно мы независимы от Google.

Важный момент про закон. Согласно 152-ФЗ push-уведомления — это обработка персональных данных (endpoint подписки + IP-адрес + поведенческие данные). Нужно получить явное согласие пользователя ДО запроса Notification.requestPermission(). Не просто браузерное «разрешить уведомления», а ваше отдельное чекбокс «Я согласен на получение уведомлений» с галочкой и логом. Подробнее писал в статье про cookie-баннер и 152-ФЗ и в общем аудите.

Шаг 7: тестирование и валидация

Готовая PWA должна пройти четыре проверки.

Lighthouse audit. Chrome DevTools → Lighthouse → Mobile → Performance + PWA + SEO. Нажимаем Generate report. По разделу PWA должно быть 100/100. Если меньше — там же написано, что не так: нет иконки, не подключён manifest, SW не контролирует start_url, нет meta theme-color. Чините по списку.

PWA Builder от Microsoft. Сервис pwabuilder.com — заливаете URL, он проверяет manifest и SW, выдаёт оценку и список рекомендаций. Дополнительно умеет генерить пакеты для магазинов приложений (см. следующий раздел).

Тест на реальном Android. Откройте сайт в Chrome на телефоне. Через 20–30 секунд внизу должен появиться banner «Установить» (если не появился — три точки в правом верхнем → Установить приложение). Установите, откройте с домашнего экрана — должно открыться без браузерной обвязки, в фуллскрине. Закройте интернет (режим полёта), откройте приложение — должна показаться кэшированная страница или offline.html.

Тест на iPhone. Safari → ваш сайт → кнопка «Поделиться» внизу → «На экран „Домой“». Подтвердите имя — иконка появилась. Откройте её — должно быть в standalone-режиме. На iOS 16.4+ через несколько секунд после открытия можно попросить разрешение на push (только из standalone-режима, не из браузера).

Чек-лист «всё работает». Manifest валидный, SW активен, иконка на рабочем столе, открывается без браузера, работает оффлайн, install button показывается на Android, push-разрешение запрашивается, тестовый push доходит. Если все 8 пунктов — поздравляю, вы сделали PWA.

Шаг 8: публикация в магазинах приложений (бонус)

PWA можно НЕ публиковать в магазинах — она и так устанавливается из браузера. Но для большего охвата (особенно если у вас уже есть бренд и трафик в RuStore/Google Play) — публикация имеет смысл.

Google Play. Через технологию TWA (Trusted Web Activity) PWA оборачивается в Android-приложение, которое внутри показывает Chrome-вкладку без обвязки. PWA Builder делает это за вас: заливаете URL, скачиваете .aab-файл, загружаете в Google Play Console. Стоимость публикации — 25$ единоразовый Google Developer fee.

RuStore. Принимает обычные .apk и .aab. Через тот же PWA Builder получаете пакет, регистрируете аккаунт разработчика (нужно ИП или ООО, никаких физлиц), загружаете. RuStore требователен к описанию, скриншотам, политике конфиденциальности на русском. Модерация в среднем 3–7 дней.

App Store. С iOS 17.4 формально возможно через PWA Builder + Xcode, но Apple до сих пор недолюбливает чистые PWA-обёртки и периодически режектит со ссылкой на пункт 4.2 «приложение должно содержать функционал, выходящий за рамки сайта». Я бы не закладывался на App Store как канал в первый год, а сосредоточился на RuStore + домашний экран через Safari.

Чек-лист на выходные

Реалистичный план для разработчика, который уже умеет вёрстку и базовый JS, но PWA не делал. Сайт уже на HTTPS, есть адаптив.

  • Суббота, 10:00–12:00 (2 часа): сгенерить иконки через RealFaviconGenerator, написать manifest.json, подключить в HTML. Проверить в DevTools → Manifest, что всё распознано.
  • Суббота, 12:30–15:30 (3 часа): написать sw.js с базовым кэшем, зарегистрировать, отладить через DevTools → Service Workers. Прогнать тест offline.
  • Суббота, 16:00–18:00 (2 часа): сделать offline.html, докрутить стратегии кэширования под свои секции (статика — cache first, страницы — network first). Зафиксировать версию кэша.
  • Воскресенье, 10:00–13:00 (3 часа): сделать install-button по событию beforeinstallprompt, отдельную инструкцию для iOS. Опционально — подключить push: сгенерить VAPID, написать subscribePush, добавить обработчик в sw.js, поднять минимальный backend на Node/Express для приёма подписок.
  • Воскресенье, 14:00–17:00 (3 часа): прогнать Lighthouse, дойти до PWA 100/100 и Performance 90+. Протестировать на Android и iOS. Опционально — собрать .apk через PWA Builder, начать публикацию в RuStore.

Итого ~13 часов. Если вы один разработчик с опытом 2+ года — спокойно успеваете за выходные. Если меньше опыта или сайт большой и сложный — закладывайте 2–3 выходных.

FAQ

Можно ли сделать PWA из сайта на Tilda или Wix?

На Tilda — с трудом. Tilda не даёт положить файлы в корень и зарегистрировать свой Service Worker, скрипты в head ограничены. Минимальное — manifest можно прицепить через «свой код», но без SW это не полноценная PWA, только иконка на рабочем столе. Wix чуть лучше — у них есть встроенная PWA-функция в Wix Studio, активируется в настройках сайта. Если нужна настоящая PWA с push и оффлайном — переезжайте на свой хостинг.

Сколько стоит сделать PWA, если не делать самому?

Простая PWA из готового лендинга или сайта — 80–150 тыс ₽, 2–4 недели. PWA с push-уведомлениями и оффлайн-режимом — 200–400 тыс ₽, 4–8 недель. PWA с интеграцией оплаты, личным кабинетом, реал-тайм синхронизацией — 500 тыс — 1,5 млн ₽, 2–4 месяца. Это всё равно дешевле нативной разработки в 3–5 раз.

PWA реально работает на iPhone?

Да, с iOS 16.4+ работают push, с iOS 17.4+ — нормальная установка. Но есть нюансы: push приходит только в установленную через «На главный экран» PWA, не в Safari-вкладку; нет API для запроса установки (только текстовая подсказка); ограничение хранилища 50 МБ на сайт (на Android — гигабайты). Для 90% сценариев этих ограничений хватает.

Можно ли публиковать PWA в App Store?

Технически с iOS 17.4 — да, через PWA Builder + Xcode. Практически — Apple часто реджектит «голые» обёртки PWA по пункту 4.2 «должен быть нативный функционал». Если делать — добавляйте хотя бы пару нативных фишек через Capacitor (биометрия, шеринг). На RuStore и Google Play проблем нет.

Что лучше — PWA или React Native?

Зависит. PWA — если у вас уже есть веб-команда и сайт, бюджет ограничен, нужно быстро выкатить на все платформы и публикация в App Store не критична. React Native — если нужны глубокие нативные интеграции (Bluetooth, NFC, биометрия), 3D, тяжёлая графика, фоновая обработка без открытого таба. Сравнение подробнее планирую в отдельной статье.

Как обновлять PWA после установки у пользователя?

Через версионирование кэша. В sw.js меняете константу CACHE = 'pwa-v1.0.3' на 'pwa-v1.0.4', заливаете новый sw.js. При следующем заходе пользователя браузер скачает новый SW, в событии activate старый кэш удалится, новый закэшируется. Если хотите принудительно перезагрузить открытую страницу после обновления — добавьте в основной скрипт слушатель navigator.serviceWorker.addEventListener('controllerchange', () => location.reload()).

Что делать, если SW «залип» и отдаёт старую версию?

Chrome DevTools → Application → Service Workers → Unregister, потом Storage → Clear site data. У пользователя — попросить переустановить PWA (удалить иконку и установить заново). Поэтому версионирование кэша — критично с первого дня.

Выводы и следующие шаги

PWA в 2026 — это уже не «модная фича», а полноценная альтернатива нативной разработке для большинства задач. Apple перестал саботировать, push работает, установка работает, в RuStore публикуется. За выходные реально успеть собрать рабочую PWA, если есть базовый опыт и сайт уже не разваливается.

Что делать прямо сейчас:

  1. Запустите Lighthouse на своём сайте, посмотрите текущий PWA-скор. Если меньше 50 — сначала вёрстка и перформанс, потом PWA.
  2. Сгенерите manifest и иконки через RealFaviconGenerator — это 30 минут, никаких знаний не нужно. Просто подключите файлы — ваш сайт уже сможет добавляться на главный экран Android.
  3. Поставьте минимальный sw.js с одним кэшем статики. Это даст 60% PWA-скора и реальный буст к скорости.
  4. Замерьте конверсию установок и сравните с обычными пользователями: средний LTV установившего PWA выше на 30–50% по моим данным с проектов 2024–2025.

Если идея зашла, но сил/времени самому копаться нет — пишите. Помогу с архитектурой или сделаем под ключ.

Застряли на каком-то шаге?

Если что-то не получается — напишите мне в Telegram, разберём вашу ситуацию. Особенно если упёрлись в push-уведомления для iOS или интеграцию в RuStore — там есть свои подводные камни, на которых я уже спотыкался.

Нужен профессиональный аудит 152-ФЗ?

Отчёт за 1–3 дня, устранение нарушений под ключ. От 5 000 ₽.