Как превратить обычный сайт в PWA за выходные: пошаговый туториал с кодом
Туториал на выходные: manifest.json, Service Worker, install-button и push-уведомления для iOS и Android. Готовый код, чек-листы и публикация в RuStore.
Коротко (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.
/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-режима, не из браузера).
Шаг 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, если есть базовый опыт и сайт уже не разваливается.
Что делать прямо сейчас:
- Запустите Lighthouse на своём сайте, посмотрите текущий PWA-скор. Если меньше 50 — сначала вёрстка и перформанс, потом PWA.
- Сгенерите manifest и иконки через RealFaviconGenerator — это 30 минут, никаких знаний не нужно. Просто подключите файлы — ваш сайт уже сможет добавляться на главный экран Android.
- Поставьте минимальный sw.js с одним кэшем статики. Это даст 60% PWA-скора и реальный буст к скорости.
- Замерьте конверсию установок и сравните с обычными пользователями: средний LTV установившего PWA выше на 30–50% по моим данным с проектов 2024–2025.
Если идея зашла, но сил/времени самому копаться нет — пишите. Помогу с архитектурой или сделаем под ключ.
Застряли на каком-то шаге?
Если что-то не получается — напишите мне в Telegram, разберём вашу ситуацию. Особенно если упёрлись в push-уведомления для iOS или интеграцию в RuStore — там есть свои подводные камни, на которых я уже спотыкался.
Нужен профессиональный аудит 152-ФЗ?
Отчёт за 1–3 дня, устранение нарушений под ключ. От 5 000 ₽.