# Turn a server-rendered site into an installable app (PWA) with native push

> synsema init --pwa gives any Synsema site a manifest, a service worker, icons and Web Push. It installs on Android, iOS and desktop with a home-screen icon and full screen — nothing native, no store.

Published 2026-09-02 · https://synsema.org/blog/installable-app-pwa-native-push


An installable web app needs three things a normal site does not have: a manifest, a service
worker and a few lines in `<head>`. Synsema scaffolds all of it, and the push notifications on top,
so a site that renders on the server becomes an app on the phone without a native client.

## Scaffold it

```sh
synsema init myapp --pwa && cd myapp
synsema serve app.syn
```

You get `public/manifest.webmanifest`, `public/sw.js` (caches the shell, never `/api/`, never a
failed response), `public/app.js` (registers the worker, shows the install button, subscribes to
push), an SVG icon with the PNG sizes generated from it, the API in `api.syn` (an `export routes`
group with the push routes and their per-route rate limits, mounted by `app.syn` — since 0.6.19),
and a one-off `push_keys.syn` that prints your VAPID key pair for `.env`.

The server side is the `serve` you already know:

```synsema
require serve(8080)
require file.read("index.html")

serve on 8080
    static "/" from "./public" cache "1h"     -- sw.js must live at /sw.js (scope "/")
    route "GET /"
        give render("index.html", {"title": "My app"})
```

`.webmanifest` is served as `application/manifest+json`, which is what makes browsers accept it.

## Push, end to end

The page reads the public key from `GET /api/push/config`, subscribes, and posts the subscription
to `POST /api/push/subscribe`. Sending is one builtin:

```synsema
let r be push_send(sub, {"title": "Order shipped", "body": "Arrives Tuesday"}, {"vapid": keys})
when r["gone"]
    forget_subscription(sub)      -- 404/410: the browser dropped it
```

Declare one `require net(...)` per push service you deliver to (FCM, Windows, Mozilla, Apple), and
keep the send behind `requires auth` or inside a cron — never in a public route.

## Where it installs

Android and desktop Chromium show the install prompt; iOS installs from *Share → Add to Home
Screen* and gets push once installed, on a trusted certificate. That last part is one flag —
see [Automatic HTTPS in one flag](/blog/automatic-https-lets-encrypt-one-flag). Firefox has no
install or app mode: there the site works as a site.

## And on the desktop, as a real app

The same files are a desktop app: `synsema init --desktop` adds `desk.syn`, which serves the same
page and the same `api.syn` on `127.0.0.1`, opens it as an app window of the browser already on
the machine and quits when the last window closes. `synsema build desk.syn -o desk --serve
--no-console --icon public/icon.svg` makes it a `.exe` with your icon (a `.app` on macOS, a
launcher folder on Linux with `--bundle`). How it works, and what each OS does with it, is in
[Ship your web app as a desktop app](/blog/desktop-app-one-binary-browser-window).

**Already have a site?** Run `synsema init --pwa` inside it: nothing of yours is overwritten, the
factory files land beside yours. Details and the iOS notes are in the
[docs](https://synsema.dev/en/0.6.x/41b-pwa), and you can try the language first at
[try.synsema.org](https://try.synsema.org).

