Shiny app-packages without a server

New webR and shinyelectron chapters in Shiny App-Packages

Shiny
webR
Deployment
Author

Martin Frigaard

Published

October 15, 2026

I’ve finally added webR and shinyelectron chapters to my Shiny App-Packages text (under the Deploy section).

In this post I’ll cover what each chapter does with sap (the book’s app-package), and a few of the problems I encountered along the way.

Where does the app run?

Every deployment in the earlier book chapters (Connect Cloud, Docker, GitHub Actions) needs a server. These two chapters move R (and the app) onto the user’s computer.

The diagram below shows where R runs in each case:

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":false}}}%%
flowchart TD
    Pkg(["<code>sap</code><br>app-package"])
    subgraph Srv["<strong>Server</strong>"]
        RS("R process on a host<br>(Connect Cloud, Docker)")
    end
    subgraph Web["<strong>Browser</strong>"]
        Live[["<code>shinylive::export()</code>"]]
        WR("<code>webR</code> session<br>in the browser tab")
    end
    subgraph Desk["<strong>Desktop</strong>"]
        SE[["<code>shinyelectron::export()</code>"]]
        EL("Electron window with<br>portable R or <code>webR</code>")
    end
    Pkg -->|"i. deploy"| RS
    Pkg -->|"ii. export"| Live
    Live --> WR
    Pkg -->|"iii. export"| SE
    SE --> EL

    style Srv fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style Web fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style Desk fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style Pkg fill:#FFFFFF,color:#000000,stroke:#333
    style RS fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Live fill:#4CBB9D,color:#FFFFFF
    style WR fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style SE fill:#4CBB9D,color:#FFFFFF
    style EL fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5

The chapters build on each other: The shinyelectron desktop chapter reuses the launcher from the webR browser chapter, and the Linux build depends on the WebAssembly binary we set up for the browser.

webR: the app in a browser tab

webR is R compiled to WebAssembly, a binary format that modern browsers run at near native speed. shinylive bundles a Shiny app, webR, and the app’s packages into a folder of static files. A static file host is just a web server that hands out files without running any code. GitHub Pages or Netlify can serve the app.

After the first download, every click stays in the browser.

What the browser doesnt’t do

The browser takes on the server’s job, but it can’t do all of it. In my opinion, these trade-offs matter most:

  • The first time the app loads, the browser downloads webR and every package before the app appears.
  • All the code and data go to the browser, so never include credentials.
  • You can’t call on system tools or downloads (i.e., system() calls fail, sass::font_google() needs local = FALSE)
  • Temporary files like logs will disappear when the tab closes

Every package needs a binary

webR can’t install packages from source, so each package in Imports needs a WebAssembly binary. The chapter checks DESCRIPTION against the webR repository:

wasm_pkgs <- available.packages(
  contriburl = "https://repo.r-wasm.org/bin/emscripten/contrib/4.6"
)
base_pkgs <- rownames(installed.packages(priority = "base"))
imports <- read.dcf("DESCRIPTION", fields = "Imports")
imports <- trimws(strsplit(imports, ",")[[1]])
imports <- imports[imports != ""]
setdiff(imports, c(rownames(wasm_pkgs), base_pkgs))

An empty result (character(0)) means every dependency has a binary.

sap itself isn’t on CRAN, so it has no binary. R-universe builds one on each push. Setting it up takes a <username>.r-universe.dev repository, a packages.json file, and the R-universe GitHub app.

ImportantInstall the GitHub app first

Nothing builds until the R-universe GitHub app is installed. Use the installation link, not the repository settings.

We then install sap from the universe, because shinylive uses each installed package’s source to find its binary.

A launcher, not launch_app()

shinylive exports a folder with an app.R file, not a package. The chapter adds a small launcher in inst/webr/app.R:

library(shiny)
library(sap)

shinyApp(
  ui = movies_ui(bslib = TRUE),
  server = movies_server
)

The launcher returns a Shiny app object instead of calling the standalone app function (launch_app()). We use shinyApp() in inst/webr/app.R because shiny::runApp() starts a server and blocks the R session. The library(sap) call above is also important because shinylive scans app.R for library() calls to decide which binaries to bundle.

Exporting and previewing take two lines:

shinylive::export(appdir = "inst/webr", destdir = "_site")
httpuv::runStaticServer("_site")

Opening _site/index.html directly won’t work, because webR needs its files served over HTTP.

shinyelectron: the app as a desktop program

Some users need the app as a program they install. They might work offline, or their organization might block unknown websites. Electron is a framework for desktop programs built with web technologies; an Electron app is just a web browser with a single page. shinyelectron wraps a Shiny app in Electron, so the user never sees a browser or a URL.

Choosing a runtime strategy

The app still needs R, and shinyelectron offers five ways to provide it. Each strategy trades installer size against what the user’s computer needs:

Strategy The installer ships The user needs
shinylive Electron and a webR bundle nothing
bundled Electron and a portable copy of R nothing
auto-download Electron and a downloader internet on first launch
system Electron only R installed
container Electron and a container setup Docker or Podman

The chapter uses bundled for macOS and Windows, which installs sap from local source. Portable R only exists for those two platforms, and bundled can’t cross-build.

The electron/ folder

The package itself doesn’t change. All the desktop code lives in an electron/ folder: the same launcher app.R from the webR chapter, a _shinyelectron.yml configuration file, and a build.R script. The configuration file holds the setting I found most useful:

dependencies:
  r:
    local_packages:
      - "../.."

local_packages installs sap from the package root, which is how a bundled app gets a package that isn’t on CRAN. The CRAN release of shinyelectron (0.2.1) doesn’t support this field yet, so the chapter installs the development version from GitHub.

Building the .dmg

The build script checks the system with sitrep_shinyelectron(), validates the app with app_check(), and calls export(). On an Intel Mac, the result is a .dmg of about 256 MB, most of it portable R.

Most of the build warnings are harmless. The NB notes are worth fixing: sap uses the native pipe, so R CMD build raised Depends to R 4.1.0.

ImportantYour .Rprofile comes along

The bundled R reads ~/.Rprofile. If it calls a package the app doesn’t include, the build fails. Wrap those calls in requireNamespace().

For Windows, a GitHub Actions matrix runs shiny-to-electron on macOS and Windows runners.

Building the .deb

The Linux build took the most work. I use Pop!_OS (an Ubuntu-flavored Linux distro), and shinyelectron will only build an .AppImage on Linux. bundled isn’t an option either, because there’s no portable R for Linux.

I found a fix by combining both chapters: The shinylive strategy runs sap with webR inside the Electron window, which uses the R-universe binary from the webR chapter. export() builds the .AppImage and leaves an Electron project behind. The build script then edits that project’s package.json and rebuilds it with electron-builder:

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":false}}}%%
flowchart TD
    Start(["<code>electron/build.R</code>"])
    Export("<code>export()</code> converts the app<br>to shinylive and builds Electron")
    AppImage[/".AppImage"/]
    Project[("<code>electron-app/</code><br>Electron project")]
    Edit("Add the <code>deb</code> target,<br>homepage, and maintainer")
    Builder("<code>electron-builder</code><br>packages the project")
    Deb[/".deb"/]

    Start -->|"i. export"| Export
    Export --> AppImage
    Export --> Project
    Project -->|"ii. edit package.json"| Edit
    Edit -->|"iii. npx electron-builder"| Builder
    Builder --> Deb

    style Start fill:#FFFFFF,color:#000000,stroke:#333
    style Export fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Edit fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Builder fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5

The edit adds three fields, because electron-builder refuses to build a .deb without a homepage and a maintainer:

pkg <- jsonlite::read_json(pkg_json)
pkg$homepage <- "https://github.com/mjfrigaard/sap"
pkg$build$linux$target <- list("AppImage", "deb")
pkg$build$linux$maintainer <- "Martin Frigaard <mjfrigaard@pm.me>"
jsonlite::write_json(pkg, pkg_json, auto_unbox = TRUE, pretty = TRUE)

Pop!_OS ships Node.js 18, which is too old. install_nodejs() installs a newer version, and withr::with_path() puts it ahead of the system version before calling electron-builder.

After sudo dpkg -i, the 183 MB .deb adds Movie Reviews to the Applications launcher.

Recap

The two chapters take sap off the server. webR and shinylive run it in the browser, with a slower first load and code that every user can see. shinyelectron turns it into an installer, with bundled R on macOS and Windows and webR on Linux. Both start from the same launcher app.R, and neither changes the package itself.