Shiny, plumber and Web APIs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Almost everything on this page is add-on packages, because R itself has no web framework: shiny 1.14.0 (interactive apps; its dependency bslib 0.12.0 and htmltools 0.5.9 build the pages) and plumber 1.3.3 (HTTP APIs). Both serve HTTP with httpuv 1.6.17 (httpuv package documentation). The scripts also use httr2 1.3.0 (to call the running app and API), jsonlite 2.0.0 (JSON, used by both frameworks), callr 3.8.0 (to start a server in a second R process; callr package documentation: r_bg), mirai 2.7.3 with later 1.4.8 and promises 1.5.0 (for ExtendedTask), and the default lintr 3.4.0 linters; the command-line curl 8.5.0 of the test image also probes the servers. plumber2 0.2.0 is installed but is a pre-1.0 package: it is described, not run. Four tools named below are not installed in the test image and are therefore described and never run: reactlog, shinytest2, rsconnect and pool. Nothing on the page needs the internet: every request goes to 127.0.0.1 and to a port chosen here (8765 for the dashboard, 8766 for the shelf API, 8767 for a small programmatic API), served by a process that the script starts and stops itself.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes (the names loanTableUI() and loanTableServer() follow the Shiny convention of a camel-case UI/Server suffix, and a server function that reads loans from the global environment is a variable lintr cannot see). The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: code that needs them starts with source("scenario.R"). Two files are different. app.R (a Shiny app) and plumber.R (a plumber API) are not scripts for Rscript: they are files that shiny::runApp() and plumber::plumb() read, each in a project directory of its own, ~/shelf-app and ~/shelf-api, next to copies of books.csv, loans.csv and scenario.R. They are shown byte for byte as they were run and they pass lintr too. The scripts that start, probe and stop them run from inside those directories, which is why they use "." and "plumber.R" and never an absolute path. Where a message contains a bullet or a quotation mark it is the typographic character R prints in a UTF-8 locale.
This page ends the performance and interoperability group. The client side of everything here (calling an API from R) is HTTP and Web APIs, which used plumber only as a test double; JSON is explained by Serialization: RDS, RData and JSON; a Shiny app inside a Quarto document is in Reports with Quarto and R Markdown; background processes, promises and mirai are in Parallel and Asynchronous Computing; and a connection pool for an app that talks to a database is a one-line addition to Databases with DBI. The Python counterpart of the API half is Serving Data Science & AI, compared in a section below.
Two ways to put R on the web
R itself ships no web server for applications. Base R has url() connections for being a client (Connections and the File System), and an internal HTTP server on the loopback interface for its help system that is not meant for anything else (tools::startDynamicHelp(), R documentation: startDynamicHelp). Two packages fill the gap, and they answer different questions.
| Shiny | plumber | |
|---|---|---|
What it serves |
a web page that changes while the user works (inputs, plots, tables) |
data and results as HTTP resources, for programs: JSON, CSV, images |
Who calls it |
a person in a browser |
other code: a front end, another service, a script, |
What you write |
a |
plain R functions with |
State |
one R session object per connected browser; reactive values live in it |
none by design: each request is independent |
Documents itself |
no |
yes, an OpenAPI document and an interactive page |
Python counterpart |
Shiny for Python (Shiny for Python documentation), Streamlit, Dash |
FastAPI, Flask |
Source: Shiny documentation: Get started
They share a foundation. Both are built on httpuv (its description calls it a building block for other packages, built on the libuv and http-parser C libraries), and both import it (the script below checks it, with the versions used). That foundation fixes the one property that shapes every design decision in this page: R is single-threaded. A Shiny process serves every browser session connected to it, and a plumber process serves every request, with one thread of R, so a computation that takes two seconds delays whatever else is waiting for the same process. The plumber manual states it for APIs (its "Runtime" article: R can only do one thing at a time, so keep endpoints fast, move long jobs out of the API process, and run several R processes behind a load balancer), and the help page of ExtendedTask states it for Shiny (an observer, calculation or output that is busy blocks the session from receiving any inputs). The sections on ExtendedTask and on concurrency below show the ways out.
# The packages this page uses, and what shiny and plumber are built on.
pkgs <- c(
"shiny", "bslib", "htmltools", "httpuv", "plumber", "plumber2",
"httr2", "jsonlite", "callr", "mirai", "later", "promises"
)
vapply(pkgs, \(p) as.character(packageVersion(p)), character(1))
#> shiny bslib htmltools httpuv plumber plumber2 httr2 jsonlite
#> "1.14.0" "0.12.0" "0.5.9" "1.6.17" "1.3.3" "0.2.0" "1.3.0" "2.0.0"
#> callr mirai later promises
#> "3.8.0" "2.7.3" "1.4.8" "1.5.0"
# Both frameworks import httpuv, the web server; shiny imports bslib.
deps <- tools::package_dependencies(
c("shiny", "plumber"),
db = installed.packages(),
which = "Imports"
)
uses <- \(d) c(httpuv = "httpuv" %in% d, bslib = "bslib" %in% d)
vapply(deps, uses, logical(2))
#> shiny plumber
#> httpuv TRUE TRUE
#> bslib TRUE FALSE
bslib is in the Imports field of shiny 1.14.0 (bslib (>= 0.6.0)), so bslib::page_sidebar() and its relatives are available in every Shiny installation and need no separate install.packages() call. plumber does not import bslib and has no user interface of its own.
Shiny: the user interface and the server function
A Shiny app is two pieces of R. The user interface (ui) is an HTML page written with R functions: fluidPage(), sidebarLayout(), selectInput() and the rest each return an HTML tag object, and a page is a tree of them. The server function is function(input, output, session), called once per browser session: it reads values the user has set from input, assigns what should appear to output, and uses session for everything about the connection. shinyApp(ui, server) pairs them into an app object (class shiny.appobj), and runApp() starts it, serving the page and talking to the browser over a WebSocket. Everything the server function defines is private to one session: two people looking at the same app have two copies of every reactive value in it.
The first script builds the pieces without a server. Inputs print as the HTML they will become, a page is a shiny.tag.list, and the Bootstrap version a page brings with it depends on the layout function: fluidPage() still uses Bootstrap 3 (3.4.1 here), while the bslib page functions use Bootstrap 5 (5.3.8 with bslib 0.12.0).
library(shiny)
# Inputs and outputs are R functions that return HTML.
actionButton("refresh", "Refresh")
#> <button id="refresh" type="button" class="btn btn-default action-button"><span class="action-label">Refresh</span></button>
textInput("search", "Search titles")
#> <div class="form-group shiny-input-container">
#> <label class="control-label" id="search-label" for="search">Search titles</label>
#> <input id="search" type="text" class="shiny-input-text form-control" value="" data-update-on="change"/>
#> </div>
selectInput("member", "Member", choices = c("Ana Ruiz", "Ben Okafor"))
#> <div class="form-group shiny-input-container">
#> <label class="control-label" id="member-label" for="member">Member</label>
#> <div>
#> <select id="member" class="shiny-input-select"><option value="Ana Ruiz" selected>Ana Ruiz</option>
#> <option value="Ben Okafor">Ben Okafor</option></select>
#> <script type="application/json" data-for="member" data-nonempty="">{"plugins":["selectize-plugin-a11y"]}</script>
#> </div>
#> </div>
# A page assembles them; the layout functions are HTML builders too.
ui <- fluidPage(
titlePanel("Shelf loans"),
sidebarLayout(
sidebarPanel(
selectInput("member", "Member", choices = c("Ana Ruiz", "Ben Okafor")),
sliderInput("months", "Months shown", min = 3, max = 18, value = 12),
dateRangeInput("range", "Loan dates")
),
mainPanel(
textOutput("summary"),
plotOutput("plot"),
tableOutput("table")
)
)
)
class(ui)
#> [1] "shiny.tag.list" "list"
# The server function is plain R; shinyApp() pairs it with the UI.
server <- function(input, output, session) {}
app <- shinyApp(ui, server)
class(app)
#> [1] "shiny.appobj"
# fluidPage() still builds on Bootstrap 3; bslib page functions use Bootstrap 5.
bootstrap_version <- function(page) {
deps <- htmltools::findDependencies(page)
Filter(\(d) d$name == "bootstrap", deps)[[1L]]$version
}
bootstrap_version(ui)
#> [1] "3.4.1"
bootstrap_version(bslib::page_sidebar(title = "Shelf loans"))
#> [1] "5.3.8"
Source: Shiny documentation: fluidPage
The layout functions are interchangeable ways to arrange the same inputs and outputs. fluidPage() with titlePanel() and sidebarLayout() is the classic form that most tutorials and the Shiny gallery use (Shiny documentation: sidebarLayout). bslib::page_sidebar() is the modern equivalent: it takes a title, a sidebar = argument built with bslib::sidebar(), and then the main content, usually in bslib::card() boxes, and it brings Bootstrap 5 (bslib package documentation: page_sidebar). The shelf dashboard below uses it; the server function does not change between the two. Passing theme = bslib::bs_theme(version = 5) to fluidPage() is the middle road (shiny 1.6.0 made the theme argument of fluidPage(), navbarPage() and bootstrapPage() understand bslib::bs_theme() objects).
Where the code lives
runApp(appDir) accepts several shapes, all listed on its help page (Shiny documentation: runApp): a directory that holds app.R, a directory that holds server.R with either ui.R or a www/index.html, an .R file that ends in an expression producing an app object, a list with ui and server, or an app object itself.
-
app.Ris the single-file form and the one to start with: the file definesuiandserver(or builds the app inline) and ends withshinyApp(ui, server). It is a file thatshiny::runApp()reads rather than a script that is run to completion:Rscript app.Rhappens to work too (the app object is the last value, printing an app object runs it, and the process then serves until it is interrupted, on a random port unless one is set), but a server that never returns is not an example this page can run and print. -
ui.Randserver.Rare the older two-file form.ui.Rends with the UI object,server.Rwith the server function, and an optionalglobal.Ris run once, before both, for code and data every session shares. -
R/next to the app file: since shiny 1.5.0,runApp()sources every top-level.Rfile of anR/directory in alphabetical order, afterglobal.Rif there is one, so helpers and modules can live in files of their own (loadSupport()is the function behind it; a file named_disable_autoload.RinR/oroptions(shiny.autoload.r = FALSE)opts out). Everything shiny reads this way is in Shiny documentation: loadSupport. -
www/holds static files (an image, a stylesheet, a script) that the app serves at the root URL, as inimg(src = "logo.png"). -
shinyAppTemplate()writes a starting project withapp.R, an example inR/, a module and atests/directory (Shiny documentation: shinyAppTemplate).
An app can also be a package: put the app in inst/ and call shiny::runApp(system.file("app", package = "mypkg")), or export a function that returns shinyApp(ui, server), which is a shape that is easy to test. Creating Packages covers the package side.
Inputs, outputs and the render functions
Every input is a function whose first argument is an inputId, the name under which the server sees the value: input$member for selectInput("member", ...). Every output is a placeholder in the UI with an outputId and is filled by assigning a render function to output$<outputId>. The two lists below are the ones the dashboard uses and the ones most apps start with.
| Input | Value on the server (input$id) |
Notes |
|---|---|---|
|
a character string, |
updates on change; |
|
a character vector; |
|
|
a number, a date or a date-time, or a vector of two for a range |
a length-two |
|
a |
|
|
an integer of class |
0 counts as "falsy": |
The "Server value" text in that table is from the help pages of each input (selectInput, sliderInput, dateRangeInput, textInput); the rest of the family (numericInput(), checkboxInput(), radioButtons(), fileInput(), passwordInput()) works the same way. An input changes on the server only when the browser sends the new value, and the server can change it back by calling an update*() function, such as updateSelectInput(session, "member", selected = "All members"), which is why session is a parameter of the server function.
| Output placeholder in the UI | Render function on the server | What it shows |
|---|---|---|
|
|
a character string (Shiny documentation: renderPrint and renderText); |
|
|
a data frame or matrix as a plain HTML table (Shiny documentation: renderTable); |
|
|
whatever the block draws, as an image sized to the output container (Shiny documentation: renderPlot) |
|
|
HTML built on the server, for inputs and layout that depend on other inputs (Shiny documentation: htmlOutput and uiOutput) |
Source: Shiny documentation: renderUI
A render function takes one block of code and returns a function; assigning it to output$id registers it. The block is not run where it is written. It is run later, by Shiny, whenever something it read has changed, and that is the subject of the next section. Two other members of the server function’s signature are worth knowing before that. session is an environment of connection facts and actions (session$clientData for the browser’s URL and output sizes, session$onSessionEnded() for clean-up, session$ns() inside a module, session$userData for per-session storage, session$sendCustomMessage() for talking to your own JavaScript), documented as one list in Shiny documentation: session. And onStop() registers code that runs when the app exits when it is called outside the server function (closing a database pool is the usual use), or when one session ends when it is called inside it (Shiny documentation: onStop).
input is read-only, and reactive values need a reactive context
Two errors greet every beginner. The first is reading input$x where nothing can depend on it: at the top level of the server function, in a plain function, in a script. Shiny’s own message says what is wrong and what to do. The second is writing to input: the browser owns it. Both are captured below (the first with a plain reactiveValues() object, which is the type input is built from, because outside of a running session there is nothing to read input from), together with isolate(), the explicit way to read a reactive value once without taking a dependency on it.
library(shiny)
# A reactive value can only be read where something can depend on it.
rv <- reactiveValues(member = "Ana Ruiz")
res <- tryCatch(rv$member, error = \(e) e)
class(res)
#> [1] "rlang_error" "error" "condition"
cat(conditionMessage(res), "\n")
#> Can't access reactive value 'member' outside of reactive consumer.
#> ℹ Do you need to wrap inside reactive() or observe()?
# isolate() reads it without taking a dependency, for a moment only.
isolate(rv$member)
#> [1] "Ana Ruiz"
# input is read-only: only the browser (or a test) sets it.
server <- function(input, output, session) {
res <- tryCatch(input$member <- "Ben Okafor", error = \(e) e)
cat(conditionMessage(res), "\n")
}
testServer(server, {})
#> Can't modify read-only reactive value 'member'
Source: Shiny documentation: isolate
The bullet at the start of the second line of the message is the ℹ that the cli package prints. The Shiny article on the reactive model gives the same rule in prose: reactive values and expressions can be used only inside the "reactive world" of reactive(), render*() and observe() (Shiny documentation: Reactivity, an overview).
Reactivity
Shiny’s server function is not a program that runs from top to bottom. The code in it does two things: it declares a graph of reactive objects, and Shiny then runs pieces of that graph when something changes. Getting this model right removes most of the confusion that new Shiny code causes; the official article names the three roles (Shiny documentation: Reactivity, an overview).
-
Reactive sources hold values and can only be read.
input$memberis one; so are the values ofreactiveVal()andreactiveValues(). -
Reactive conductors sit between: they read sources (and other conductors) and produce a value that others can read. A
reactive()expression is the implementation of a conductor. It caches its last value. -
Reactive endpoints are the nodes at the end that cause something to happen: an observer created with
observe()orobserveEvent(), and everyoutput$x <- render*()assignment, which Shiny wraps in an observer. An endpoint has no value that others can read.
Edges are dependencies that Shiny discovers by running the code. While an expression runs, every reactive object it reads registers the expression as a dependent. When a source changes, it invalidates its dependents, and they invalidate theirs; that is the "invalidates" arrow in the diagram of the dashboard below. Invalidation only marks things dirty. A flush then re-runs the invalidated endpoints, and a conductor runs only if an endpoint that is being run asks for its value and finds it dirty. The execution-scheduling article walks through it with a Fibonacci example and states the consequences that matter in practice (Shiny documentation: Execution scheduling):
-
The edges are dynamic, not static: after an object is invalidated its edges are removed, and it grows new ones from whatever it reads the next time it runs. A branch not taken creates no dependency, so
if (input$show) input$ndepends oninput$nonly whileinput$showis true. -
A clean reactive expression returns its cached value without re-running. Two outputs that both read the same
reactive()cause one computation, which is the reason to write a conductor. -
When several endpoints are invalidated by one flush there is no guaranteed order of execution, so endpoints must not depend on each other through ordinary variables.
-
A flush happens whenever the browser sends data, and at the start of a session every endpoint starts out invalidated, so every visible output runs once.
The practical rules that follow are the same as for any lazy evaluation: code in reactive() runs only when read; code in observe() runs when invalidated; code outside both runs once, when the session starts. The next script demonstrates them with testServer(), Shiny’s tool for running a server function without a browser (the testing section below says more). It declares a reactive() that nothing reads, a conductor read by two outputs, an observer and an eventReactive(); each line of output comes from a cat() or print(), and the comments say what to look for.
library(shiny)
source("scenario.R")
# nolint start: object_usage_linter.
server <- function(input, output, session) {
# A reactive expression is lazy: it runs only when something reads it.
by_member <- reactive({
cat("computing for", input$member, "\n")
sum(trimws(loans$member) == input$member, na.rm = TRUE)
})
unused <- reactive({
cat("this line is never printed\n")
input$months
})
output$n <- renderText(by_member())
output$twice <- renderText(by_member() * 2L)
# An observer runs for its side effects and has no value.
seen <- reactiveVal(character())
observeEvent(input$member, {
seen(c(seen(), input$member))
})
# eventReactive() recomputes only when its event fires.
report <- eventReactive(input$go, {
paste("report for", input$member)
})
output$report <- renderText(report())
}
# nolint end
testServer(server, {
# Setting an input flushes the graph: both outputs run, by_member() only once.
session$setInputs(member = "Ana Ruiz")
print(output$n)
print(output$twice)
# Another value: the cached result is invalidated and computed again.
session$setInputs(member = "Dev Patel")
print(output$n)
print(seen())
# Before the button is pressed the eventReactive() has no value.
no_value <- tryCatch(output$report, error = \(e) e)
print(inherits(no_value, "shiny.silent.error"))
session$setInputs(go = 1)
print(output$report)
# Changing member does not re-run it; pressing the button again does.
session$setInputs(member = "Eva Lindqvist")
print(output$report)
session$setInputs(go = 2)
print(output$report)
})
#> computing for Ana Ruiz
#> [1] "10"
#> [1] "20"
#> computing for Dev Patel
#> [1] "6"
#> [1] "Ana Ruiz" "Dev Patel"
#> [1] TRUE
#> [1] "report for Dev Patel"
#> computing for Eva Lindqvist
#> [1] "report for Dev Patel"
#> [1] "report for Eva Lindqvist"
Source: Shiny documentation: reactive
Four things are visible in the output. by_member() ran once for two outputs that read it (n and twice), because the second read found the cached value. unused never ran: its cat() line is not in the output, because no endpoint depends on it, and a reactive expression that nothing reads costs nothing. Changing the input ran by_member() again and the observer appended to seen(). And the eventReactive() had no value before the first click (reading it raised the silent error that req() raises, an error of class shiny.silent.error), kept its old value when member changed, and recomputed when go changed. The count of computing lines is the quantity to watch when an app feels slow.
reactive(), observe(), observeEvent() and eventReactive()
The functions of the reactive toolbox differ in two questions: does the code produce a value, and what triggers it?
| Function | Role | Triggered by | Use it for |
|---|---|---|---|
|
conductor: returns a value, caches it |
any reactive it reads, but only runs when read |
a computed value that several outputs or other reactives use (filtering, a model fit, reading a file) |
|
endpoint: returns nothing |
any reactive it reads, runs eagerly at each flush |
side effects that are not outputs: updating an input, writing a log line, changing a |
|
endpoint |
only |
"when this button is clicked, do that" |
|
conductor |
only |
a value that is recomputed on request, as for a "Run" button |
|
modifier on a |
only the expressions in |
the same as the two above, but also for render functions and combinable with |
observeEvent() and eventReactive() are the older, shorter names (both were added in shiny 0.11, according to its NEWS). bindEvent() was added in shiny 1.6.0 as one more general mechanism: its help page says that for reactive() and observe() it does the same thing as eventReactive() and observeEvent(), and that it can additionally be used with render functions such as renderPlot() and combined with bindCache() (Shiny documentation: bindEvent). The arguments are the same in all of them: ignoreNULL = TRUE skips the event when the value is NULL (or, for a button, 0), ignoreInit = FALSE decides whether the code runs when the object is first created, and once = TRUE (observers only) destroys the observer after its first run. Because an actionButton() value is 0 before the first click and 0 is "falsy", observeEvent(input$go, ...) does not run on load unless ignoreNULL = FALSE.
reactiveVal(), reactiveValues() and isolate()
reactiveVal(initial) creates one reactive value, a function: call it with no arguments to read it (a read takes a dependency) and with one argument to set it (as in seen(c(seen(), input$member)) above). reactiveValues(a = 1, b = 2) creates a list-like object with several; read with rv$a, set with rv$a <- 3. Both are the way to keep state that outlives a single observer run, such as a selected row, a list of messages, or a counter. They are also the way not to organise an app: a reactiveVal() that is set in one observer in order to be read in another is a hand-made reactive expression with extra steps, and it hides the dependency from the graph. Prefer a reactive() whenever the value is a function of other reactive values, and a reactiveVal() when the value is state that events change.
isolate(expr) reads reactive values without taking a dependency on them, and it is the only way to read one outside the reactive world (the script above). Inside observeEvent() the handler is already isolated, which is why it can read input$member freely without re-running when the member changes.
Stopping early: req() and validate()
Most outputs have nothing sensible to show until the user has chosen something. Both functions below stop the calculation by raising a silent error, one that Shiny catches, does not log, and turns into an empty (or message-showing) output rather than an error box. req(...) checks that every argument is "truthy" (not NULL, FALSE, "", NA or length zero, and for a button not 0); validate(need(cond, message), ...) does the same and shows the message in place of the output (Shiny documentation: req, Shiny documentation: validate and need). The difference is the message: use req() when an empty output is the right answer and validate(need()) when the user needs to be told what is missing.
library(shiny)
source("scenario.R")
# nolint start: object_usage_linter.
server <- function(input, output, session) {
# req() stops silently while its arguments are missing, empty or FALSE.
output$member_loans <- renderText({
req(input$member)
sum(trimws(loans$member) == input$member, na.rm = TRUE)
})
# validate(need()) stops with a message that the output shows to the user.
output$overdue <- renderText({
validate(need(input$member != "", "Choose a member first."))
paste("overdue for", input$member)
})
}
# nolint end
# What the browser would show for an output that stopped early.
shown <- \(expr) {
tryCatch(expr, error = \(e) {
list(class = class(e)[[1L]], message = conditionMessage(e))
})
}
testServer(server, {
session$setInputs(member = "")
str(shown(output$member_loans))
str(shown(output$overdue))
session$setInputs(member = "Dev Patel")
str(shown(output$member_loans))
str(shown(output$overdue))
})
#> List of 2
#> $ class : chr "shiny.silent.error"
#> $ message: chr ""
#> List of 2
#> $ class : chr "shiny.silent.error"
#> $ message: chr "Choose a member first."
#> chr "6"
#> chr "overdue for Dev Patel"
The helper shown() plays the part of the browser: an output that stopped early appears as a condition of class shiny.silent.error, and the only difference between the two cases is the message, empty for req() and the text of need() for validation. The same mechanism makes ExtendedTask$result() throw a silent error while the task has not finished.
bindEvent() and bindCache()
bindEvent() is shown on a conductor below: the reactive() would depend on input$member and on whatever it reads, and the binding makes it depend only on input$go. bindCache() is the other binding, added in shiny 1.6.0 with the caching of reactive() and render*() expressions: an ordinary reactive() caches its last value, and bindCache(x, ...) caches every value, keyed by the expressions in ..., and by default shares the cache across all sessions of the app (cache = "app"; "session" or a cachem cache object are the alternatives). The help page lists the rules for correctness: for one key the value must always be the same, evaluation must have no side effects, and the key must contain every reactive the value reads (Shiny documentation: bindCache). The cat() lines below are side effects added only so that the script can show when the expression ran.
library(shiny)
source("scenario.R")
# nolint start: object_usage_linter.
server <- function(input, output, session) {
# bindEvent() turns any reactive or render function into an event handler.
summary_text <- reactive({
cat("summarising", input$member, "\n")
n <- sum(trimws(loans$member) == input$member, na.rm = TRUE)
sprintf("%s has %d loans", input$member, n)
}) |>
bindEvent(input$go)
output$summary <- renderText(summary_text())
# bindCache() keeps every result, keyed by the inputs it is bound to.
slow_count <- reactive({
req(input$who)
cat("counting for", input$who, "\n")
sum(trimws(loans$member) == input$who, na.rm = TRUE)
}) |>
bindCache(input$who)
output$count <- renderText(slow_count())
}
# nolint end
testServer(server, {
session$setInputs(member = "Ana Ruiz")
no_value <- tryCatch(output$summary, error = \(e) e)
print(inherits(no_value, "shiny.silent.error"))
session$setInputs(go = 1)
print(output$summary)
session$setInputs(member = "Dev Patel")
print(output$summary)
for (who in c("Ana Ruiz", "Dev Patel", "Ana Ruiz")) {
session$setInputs(who = who)
print(output$count)
}
})
#> [1] TRUE
#> summarising Ana Ruiz
#> [1] "Ana Ruiz has 10 loans"
#> [1] "Ana Ruiz has 10 loans"
#> counting for Ana Ruiz
#> [1] "10"
#> counting for Dev Patel
#> [1] "6"
#> [1] "10"
Source: Shiny documentation: bindEvent
The first print() shows that the event-bound reactive had no value before go was set. After it, changing member did not recompute, which is the difference from a plain reactive(); and in the loop over three members, the first and second computed (counting for lines), while the third, the repeated Ana Ruiz, came from the cache without computing. For very slow work the help page recommends pairing the two so that nothing runs until the user asks.
Long computations: ExtendedTask
A synchronous calculation blocks the R process, and with it every session connected to it. The first fix, available for years, is asynchronous reactives with the promises package: they unblock other sessions while the one that started the work waits. ExtendedTask goes further and keeps even the starting session responsive, so the user can continue to use the rest of the app. It was added in shiny 1.8.1 (its NEWS entry pairs it with bslib::input_task_button() and bslib::bind_task_button() for button feedback) and is the documented tool for starting an expensive operation without blocking the session (Shiny documentation: ExtendedTask). The task function must return a promise, most likely a mirai (mirai package documentation) or a future, through the promises package (promises package documentation), and must not read reactive values itself; the caller reads them and passes them as arguments to invoke(). status() is "initial", "running", "success" or "error", and result() returns the value or throws, with the silent errors that blank or mark an output "in progress" in the other states. The script below runs a task in a mirai daemon inside testServer() and pumps the event loop with later::run_now() and session$flushReact(); in a real app Shiny does that for you.
library(shiny)
source("scenario.R")
# nolint start: object_usage_linter.
server <- function(input, output, session) {
# The task body runs in a mirai daemon, so the R process serving the app
# stays free to answer other users while it works.
members <- ExtendedTask$new(function(loans) {
mirai::mirai(
length(unique(trimws(loans$member))),
loans = loans
)
})
observeEvent(input$count, members$invoke(loans))
output$members <- renderText(members$result())
}
# nolint end
# Start one daemon and wait until it answers, so the task below does not
# include the start-up time of the worker process.
mirai::daemons(1)
invisible(mirai::mirai(TRUE)[])
testServer(server, {
print(members$status())
session$setInputs(count = 1)
print(members$status())
for (i in 1:300) {
later::run_now(0.1)
session$flushReact()
if (members$status() != "running") break
}
print(members$status())
print(output$members)
})
#> [1] "initial"
#> [1] "running"
#> [1] "success"
#> [1] "10"
invisible(mirai::daemons(0))
mirai, future, promises and the other ways of running R code in the background are the subject of Parallel and Asynchronous Computing. Two cautions apply. A task is not a thread: it needs a second R process (a mirai daemon, a future worker), which costs memory and start-up time, and many users clicking at once will queue behind however many workers exist. And objects crossing to the worker are copied, so pass the data the task needs and not the whole session.
Debugging the graph
When an app recomputes more than expected, the tool is the reactive log. In a fresh R session, reactlog::reactlog_enable() (or options(shiny.reactlog = TRUE)) turns recording on; then start the app and press Ctrl+F3 (Command+F3 on a Mac) in the browser to open an interactive viewer of the graph and of the order of events, or call shiny::reactlogShow() for a static report with the time each node took (Shiny documentation: reactlog). The help page warns against enabling it in production: any user of the app could see some of the source code of its reactive expressions, and the log grows until the R session restarts. The viewer needs the reactlog package (reactlog package documentation), which is not installed in the image used for this page, so this paragraph is the description and nothing was run. The smaller tools are print() or cat() lines like those above, browser() placed in a reactive (the app stops in the console when the code runs), options(shiny.error = recover) (one of the options listed in Shiny documentation: shinyOptions), and shiny::devmode(TRUE), which is labelled experimental on its help page and turns on options such as non-minified JavaScript and messages about deprecated functions (Shiny documentation: devmode). They are explained for ordinary R code in Debugging.
Modules
An app grows until one server function holds forty outputs and ids such as table1, table2 and table_overdue_2. A module is Shiny’s answer: a pair of functions, one for the UI and one for the server, that share an id, so that the same piece can be used many times on one page, or once in each of several apps, without id clashes (Shiny documentation: Modules).
-
The UI function takes
id, createsns <- NS(id), and wraps every input and output id inns():ns("table")becomesoverdue-tablewhenidis"overdue".NS(id)is nothing more than a function that pastes the two with a hyphen. -
The server function takes
id(and whatever data the module needs, as arguments) and callsmoduleServer(id, function(input, output, session) {...}). Inside,input$tableandoutput$tablerefer to the namespaced ids without the prefix;session$ns("table")gives the full id if a message to JavaScript needs it. -
Data goes in as arguments. If the module needs a value that changes, pass the reactive itself (
loans, notloans()), and call it inside the module; passing the value would freeze it at the time of the call. Results come out as the return value of the module function, usually a reactive, which the caller stores and uses. -
A module’s reactives and observers are scoped to its session proxy. In shiny 1.14.0
session$destroy()is available inside a module, to tear down its reactive objects when its UI is removed dynamically (Shiny documentation: moduleServer).
The script shows NS(), a module used twice on one page (each copy has its own ids and its own counter) and the module tested on its own. testServer() accepts a module function directly; args supplies the arguments after id, and session$returned() is the value the module returned.
library(shiny)
# NS() builds the namespaced id that the module's UI and server must agree on.
ns <- NS("overdue")
ns("table")
#> [1] "overdue-table"
# A module is a UI function and a server function that share an id.
counterUI <- function(id) { # nolint: object_name_linter.
ns <- NS(id)
tagList(
actionButton(ns("add"), "Add"),
textOutput(ns("total"))
)
}
counterServer <- function(id) { # nolint: object_name_linter.
moduleServer(id, function(input, output, session) {
total <- reactiveVal(0L)
observeEvent(input$add, total(total() + 1L))
output$total <- renderText(paste("pressed", total(), "times"))
total
})
}
# The same module twice, on one page, without clashing ids.
ui <- fluidPage(counterUI("left"), counterUI("right"))
grep("id=", strsplit(format(ui), "\n")[[1L]], value = TRUE) |>
trimws() |>
substr(1L, 40L)
#> [1] "<button id=\"left-add\" type=\"button\" clas"
#> [2] "<div id=\"left-total\" class=\"shiny-text-o"
#> [3] "<button id=\"right-add\" type=\"button\" cla"
#> [4] "<div id=\"right-total\" class=\"shiny-text-"
# testServer() runs a module's server function on its own.
testServer(counterServer, {
session$setInputs(add = 1)
session$setInputs(add = 2)
print(session$returned())
print(output$total)
})
#> [1] 2
#> [1] "pressed 2 times"
Source: Shiny documentation: NS
The shelf dashboard below uses the same shape for something more useful: loanTableUI("overdue") and loanTableServer("overdue", overdue), a heading plus a table whose rows come from a reactive that the caller passes in. A module is also the unit to move out of app.R into its own file in R/, which Shiny sources automatically.
moduleServer() arrived with testServer() in shiny 1.5.0. Before that, modules were written with callModule(module, id, ...), where the server function took (input, output, session, ...) directly and the UI function was called with ns <- NS(id); that form still exists and works, but the help page for moduleServer() recommends the new one because its syntax is easier to follow and because only moduleServer() modules can be tested with testServer().
The shelf dashboard
The dashboard shows the loans of the running scenario: a member selector, a slider for how many months to show, a one-line summary, a bar chart of loans per month and a table of the overdue loans. It lives in a project directory of its own, in the layout every example in this section uses:
~/shelf-app/
app.R
books.csv
loans.csv
scenario.R
Source: Shiny documentation: runApp
app.R is read in full by shiny::runApp(); its first lines load the scenario and tidy the data once, when the app starts, so that all sessions share the result (code outside the server function runs once per process, code inside it once per session). The tidying repeats what the earlier pages explained: member names are trimmed and title-cased, loans with an impossible date are dropped, days_out is the time between the loan and the return (or shelf_today for a loan that is still out), and a loan is overdue when days_out is more than loan_days. title comes from matching the loan’s ISBN to the book’s normalised ISBN. Below the data come the loanTableUI() and loanTableServer() module, the UI as a bslib::page_sidebar() and the server function. The listing is the file as it was run; it is not a standalone script for Rscript.
# The shelf dashboard: a file run by shiny::runApp(), not a script for Rscript.
library(shiny)
source("scenario.R")
# --- tidy the shelf data once, when the app starts ---------------------------
loans$member <- tools::toTitleCase(tolower(trimws(loans$member)))
loans$member[loans$member == ""] <- NA_character_
loans <- loans[!is.na(loans$loan_date), ]
returned_or_today <- loans$return_date
returned_or_today[is.na(returned_or_today)] <- shelf_today
loans$days_out <- as.integer(returned_or_today - loans$loan_date)
loans$overdue <- loans$days_out > loan_days
loans$month <- format(loans$loan_date, "%Y-%m")
books$isbn_key <- normalise_isbn(books$isbn)
title_of <- books$title[match(loans$isbn, books$isbn_key)]
loans$title <- tools::toTitleCase(tolower(trimws(title_of)))
# The first of the last `n` months, oldest first, as "YYYY-MM" strings.
last_months <- function(n) {
this_month <- format(shelf_today, "%Y-%m-01") # nolint: object_usage_linter.
first <- as.Date(this_month)
rev(format(seq(first, by = "-1 month", length.out = n), "%Y-%m"))
}
# --- a module: the overdue table ---------------------------------------------
loanTableUI <- function(id) { # nolint: object_name_linter.
ns <- NS(id)
tagList(
h4(textOutput(ns("heading"), inline = TRUE)),
tableOutput(ns("table"))
)
}
loanTableServer <- function(id, loans) { # nolint: object_name_linter.
moduleServer(id, function(input, output, session) {
output$heading <- renderText(sprintf("%d overdue loans", nrow(loans())))
output$table <- renderTable({
validate(need(nrow(loans()) > 0, "No overdue loans for this selection."))
loans()[, c("loan_id", "title", "member", "days_out")]
})
})
}
# --- the user interface -------------------------------------------------------
ui <- bslib::page_sidebar(
title = "Shelf loans",
sidebar = bslib::sidebar(
selectInput(
"member", "Member",
choices = c("All members", sort(unique(na.omit(loans$member))))
),
sliderInput("months", "Months shown", min = 3, max = 18, value = 12)
),
textOutput("summary"),
bslib::card(bslib::card_header("Loans per month"), plotOutput("plot")),
bslib::card(loanTableUI("overdue"))
)
# --- the server function ------------------------------------------------------
server <- function(input, output, session) {
loans_sel <- reactive({
sel <- loans[loans$month %in% last_months(input$months), ]
if (input$member != "All members") {
sel <- sel[sel$member %in% input$member, ]
}
sel
})
overdue <- reactive(loans_sel()[loans_sel()$overdue, ])
output$summary <- renderText({
sprintf(
"%d loans in the last %d months, %d of them overdue",
nrow(loans_sel()), input$months, nrow(overdue())
)
})
output$plot <- renderPlot({
months <- last_months(input$months)
per_month <- table(factor(loans_sel()$month, levels = months))
barplot(per_month, las = 2, col = "steelblue", ylab = "Loans")
})
loanTableServer("overdue", overdue)
}
shinyApp(ui, server)
Source: Shiny documentation: shinyApp
Three details are worth reading twice. last_months(n) is a plain function that returns the last n months as YYYY-MM strings, and both the plot and the reactive use it, so the plot shows a bar (possibly of height zero) for every month in the window, not only for months that have loans. loans_sel() and overdue() are reactive() expressions, and overdue() reads loans_sel() rather than redoing the filter, which is the pattern the figure shows. And the table is the module: the server function passes the reactive overdue itself (not overdue()) to loanTableServer(), which calls it inside its own renderText() and renderTable(), so the module sees every change.
The reactive graph of the dashboard
Reading the server function as a graph gives the figure. Two sources (the inputs) feed two conductors, and three endpoints read them. Every arrow means "if the parent changes, the child is invalidated". Two edges are easy to miss: output$plot also reads input$months directly, to build the list of months for the zero-filled bars, and the table endpoint belongs to the module (its id is overdue-table in the browser).
Follow one change. When the user picks another member, input$member invalidates loans_sel(), which invalidates overdue(), output$plot and output$summary, and overdue() in turn invalidates output$table. The flush then re-runs the three endpoints in no guaranteed order; whichever reads loans_sel() first causes it to run, and the other readers get its cached value, so the filter runs once. When the user moves the slider, input$months invalidates loans_sel() and output$plot, and the rest follows. Nothing in the graph runs because of the passage of time or because someone else’s session changed: the graph is per session.
The note node is the other half of laziness. A conductor with no endpoint that reads it never runs. And an output that is not visible is suspended: outputOptions(output, "plot", suspendWhenHidden = FALSE) opts one out of that behaviour, and the default (TRUE) means that a plot on an inactive tab is not drawn until the tab is shown (Shiny documentation: outputOptions). That is an optimisation to know about, since an expensive output in a hidden tab costs nothing, and a trap, since a hidden output that another part of the page expects to have run will not have.
Running it without a browser
Because the server function is plain R, the dashboard can be tested without starting a server. testServer(app = ".", ...) loads the app in the current directory, which is why the script runs from ~/shelf-app, and exposes everything the server function created, including the reactives loans_sel() and overdue(), so a test can look inside as well as at the outputs. A rendered plot is a PNG data URI plus the size at which it was drawn.
library(shiny)
# Run from the app directory: "." is the folder that holds app.R.
testServer(app = ".", {
session$setInputs(member = "All members", months = 12)
print(output$summary)
session$setInputs(member = "Dev Patel", months = 18)
print(output$summary)
print(nrow(loans_sel()))
print(overdue()[, c("loan_id", "title", "days_out")], row.names = FALSE)
# The plot output is a PNG data URI plus the size it was drawn at.
print(substr(output$plot$src, 1, 22))
})
#> [1] "42 loans in the last 12 months, 24 of them overdue"
#> [1] "6 loans in the last 18 months, 4 of them overdue"
#> [1] 6
#> loan_id title days_out
#> L018 The Quiet Streets 25
#> L040 The Lamp at Noon 24
#> L056 Paper Moons 71
#> L057 The Long Table 65
#> [1] "data:image/png;base64,"
Source: Shiny documentation: testServer
With all members and 12 months the summary reports 42 loans, 24 of them overdue by the 21-day rule. For one member and 18 months, overdue() lists those loans with their title and the number of days out, and the plot is the PNG that a browser would be sent.
Starting it for real
shiny::runApp() is what a user types to run the app, from an R session in the project directory, or from anywhere with the path; the third form, listening on every interface, is the shape a container needs. These three lines are fragments for the reader to run, not a script that was run: the session blocks until it is interrupted, and prints the URL it listens on.
shiny::runApp()
shiny::runApp("~/shelf-app", port = 8765)
shiny::runApp("~/shelf-app", host = "0.0.0.0", port = 3838, launch.browser = FALSE)
Source: Shiny documentation: runApp
The arguments that matter are port (otherwise the shiny.port option, otherwise a random port between 3000 and 8000, excluding the ports Chrome blocks as unsafe), host ("127.0.0.1", which accepts connections from this machine only, unless the shiny.host option says otherwise; "0.0.0.0" accepts connections from the network, which is what a container needs and what a laptop should not do without a reason) and launch.browser, true in interactive sessions only. The next script is the proof that the app works as a server. It starts runApp() in a second R process with callr (runApp() blocks, as pr_run() does in the http page), waits until the port answers by polling, probes it with httr2 and again with the command-line curl through system2(), asks for a path the app does not have, prints what the process wrote to its standard error and stops it. The port, 8765, is chosen in the script and so it is fine to print.
library(httr2)
# Run from the app directory: "." is the folder that holds app.R.
app_dir <- normalizePath(".")
# runApp() blocks, so start the app in a background R process.
run_app <- function(dir) {
shiny::runApp(dir, port = 8765L, host = "127.0.0.1", launch.browser = FALSE)
}
srv <- callr::r_bg(run_app, args = list(dir = app_dir))
on.exit(srv$kill(), add = TRUE)
# Wait until the server answers instead of sleeping for a fixed time.
url <- "http://127.0.0.1:8765/"
for (i in 1:150) {
up <- tryCatch(req_perform(request(url)), error = \(e) NULL)
if (!is.null(up)) break
Sys.sleep(0.2)
}
srv$is_alive()
#> [1] TRUE
resp <- req_perform(request(url))
resp_status(resp)
#> [1] 200
resp_content_type(resp)
#> [1] "text/html"
html <- resp_body_string(resp)
regmatches(html, regexpr("<title>[^<]*</title>", html))
#> [1] "<title>Shelf loans</title>"
# The same probe with curl, printing only the status code.
system2("curl", c("-s", "-o", "/dev/null", "-w", "'%{http_code}'", url),
stdout = TRUE)
#> [1] "200"
# Shiny listens on every path of the app; a made-up one is a plain 404.
bad <- request(paste0(url, "nothing-here")) |>
req_error(is_error = \(r) FALSE) |>
req_perform()
resp_status(bad)
#> [1] 404
# The messages the app printed while starting, then stop it.
srv$read_error_lines()
#> [1] "Loading required package: shiny" ""
#> [3] "Listening on http://127.0.0.1:8765"
invisible(srv$kill())
srv$is_alive()
#> [1] FALSE
Source: Shiny documentation: runApp
The response is an ordinary HTML page whose <title> comes from the title of page_sidebar(). The status of the page itself is 200 for both clients; nothing in the HTML says anything about the plot or the table, because Shiny fills the outputs through a WebSocket after the page has loaded, so a plain HTTP probe proves that the server is up and that the app file loaded without error but not that an output renders (that is what testServer() and shinytest2 are for). The start-up output on the standard error is a loading message from runApp(), an empty line and the Listening on line with the URL. When app.R has an error the process exits instead: with a stop("the data file is missing") in the file (tried while preparing this page), srv$is_alive() was FALSE, srv$get_exit_status() was 1 and srv$read_error_lines() ended with Error in ..stacktraceon..({ : the data file is missing. That is why a real script checks is_alive() inside its polling loop and prints read_error_lines() when it gives up.
Shiny 1.14.0 added shiny::startApp() to the same family: it returns immediately with a handle that has stop(), status(), url() and result() methods, serving the app from the R session’s event loop, and starting a new app stops the previous one (Shiny documentation: startApp). It is the interactive way to keep the console free; since a client in the same process would block the server, the scripts here keep the second process and callr.
Testing Shiny apps
Three levels of test exist, from cheap to expensive.
-
Plain functions. Anything that does not need reactivity (
last_months(), the tidying of the data) is an ordinary R function and is tested with testthat as in Testing with testthat. The more of an app’s logic is written that way, in files underR/, the less needs the other two levels. -
testServer(). Runs a server function or a module without a browser, as the scripts above did (Shiny documentation: testServer). The code block is evaluated with the server function’s objects (input,output,sessionand everything defined in it, such asloans_sel()) in scope.session$setInputs()sets inputs as the browser would and flushes the reactive graph;session$flushReact()flushes without changing an input;session$returned()is a module’s return value.appcan be a server function, a module function (withargs), a path to an app directory, or an app object. It was added in shiny 1.5.0. What it cannot do: run JavaScript, render real layout, or notice a brokenui; and in the scripts above every output ran at each flush, so it does not reproduce the hidden-output suspension described with the figure above. -
shinytest2. Renders the app in a real, headless Chrome through the chromote package, so JavaScript and CSS are exercised;
shinytest2::record_test()opens the app and records your actions as test code, and the tests are testthat tests (shinytest2 package documentation). It is the right tool for the layout and the JavaScript, and it is slower and more fragile, so a project normally has manytestServer()tests and a few shinytest2 ones. shinytest2 is not installed in the image used for this page, so it is described here and nothing about it was run.
testServer() works inside testthat, which is where the expectations belong. The script below is the body of such a test for the dashboard, with one expectation caught and printed to show the message a failure produces. In a project, files named test-*.R in tests/testthat/ hold these, with a tests/testthat.R that calls test_check(), and shiny::runTests() runs the tests of an app directory; shinyAppTemplate() creates that layout (Shiny documentation: runTests).
library(shiny)
library(testthat)
# Run from the app directory. In a project these lines live in a file such as
# tests/testthat/test-server.R, inside test_that("...", { ... }).
testServer(app = ".", {
session$setInputs(member = "Dev Patel", months = 18)
expect_equal(nrow(overdue()), 4L)
expect_match(output$summary, "4 of them overdue")
# A failing expectation says what differed; here it is caught and printed.
failed <- tryCatch(expect_equal(nrow(overdue()), 5L), error = \(e) e)
cat(conditionMessage(failed), "\n")
})
#> Expected `nrow(overdue())` to equal 5L.
#> Differences:
#> 1/1 mismatches
#> [1] 4 - 5 == -1
Source: Shiny documentation: testServer
Deploying Shiny apps
An app that works under runApp() on a laptop is not yet something others can use. Deploying it means running an R process, with the right packages and files, behind a URL that allows WebSocket connections, and restarting it when it dies. The official deployment page lists the options and what each is for (Shiny documentation: Hosting and deployment).
| Option | What it is | Consider it when |
|---|---|---|
shinyapps.io |
Posit’s hosting service for Shiny apps, with free and paid plans; you upload the app with |
one app, no servers to manage, public or shared with a few people |
Posit Connect |
Posit’s commercial publishing platform on your own servers, for Shiny apps, R Markdown and Quarto documents, APIs (plumber) and Python content, with push-button publishing, scheduled execution and access control |
a team that publishes many kinds of content and needs authentication |
Posit Connect Cloud |
Posit’s online publishing platform that deploys Shiny apps and other content from GitHub |
publishing from a repository without running Connect yourself |
Shiny Server (open source) |
a server for hosting multiple apps on a single machine and for deploying apps behind firewalls |
you own a Linux server and want the open-source route |
a container |
your own image running |
you already deploy containers; see Docker |
ShinyProxy |
an open-source application, a Spring Boot web application on the JVM (ShinyProxy documentation), that starts a container for every user who runs an app, from the app’s Docker image, with the usual authentication technologies |
many users, a session per container for isolation, a container platform |
The same page notes that Posit stopped selling Shiny Server Pro to new customers in favour of Posit Connect, which is why older tutorials that mention Shiny Server Pro describe a product that exists now only for existing customers. Deployment to shinyapps.io and Connect uses the rsconnect package. It is not installed in the image used for this page, so the two lines below are fragments, never run: they show how an account is registered once and how a directory is sent. deployApp() bundles the files in the directory (all of them apart from the standard exclusions and those listed in a .rscignore file) and works out the package dependencies from the local library; with the default dependencyResolution = "strict" an renv.lock file is used if there is one and must match the local library (rsconnect package documentation: deployApp). rsconnect::writeManifest() writes the manifest.json that Connect uses for deployment from a Git repository.
rsconnect::setAccountInfo(name = "shelf", token = "<token>", secret = "<secret>")
rsconnect::deployApp("~/shelf-app", appName = "shelf-loans")
The placeholders <token> and <secret> are where the account page of the service supplies the credentials; they never belong in a script that goes into version control. For a container, the file below is a fragment too (no image was built for this page): it installs the packages the app uses at pinned versions, copies the directory and runs the app on the interface a container needs.
FROM rocker/r-ver:4.6.1
RUN R -q -e 'install.packages(c("shiny", "bslib"))'
COPY shelf-app /app
EXPOSE 3838
CMD ["R", "-q", "-e", "shiny::runApp('/app', host = '0.0.0.0', port = 3838)"]
A few practical rules decide whether a deployment survives its first week.
-
One process serves many sessions. The tidied
loansobject is loaded once and shared; code in the server function runs per session. Heavy start-up work belongs outside the server function, and per-user state inside it. When one process is not enough, run several behind a proxy or use a platform that manages the processes for you. -
The proxy must forward WebSockets. Shiny talks to the browser over a WebSocket; a reverse proxy (nginx, Apache, a cloud load balancer) that closes idle connections or does not upgrade the connection typically shows up as an app that greys out after a while.
session$allowReconnect(TRUE)lets the client reconnect on platforms that support it. -
Dependencies must match. Capture the packages with renv (Reproducible Environments with renv); deploying from a different R or package version than the one tested is the usual cause of an app that worked on the laptop.
-
Configuration comes from the environment. Read secrets and server addresses with
Sys.getenv()(R documentation: Sys.getenv), never from the file. Database connections should come from a pool rather than one connection per session (the pool package, pool package documentation, described in one line on Databases with DBI; it is not installed here and was not run). -
The app is a web page. Everything the user types arrives as input to be validated. Shiny escapes the text it renders, but code that builds HTML from input with
HTML()or reads files named by input needs the same care as the plumber examples below. -
Documents can host an app too. A Quarto or R Markdown document with
server: shinyin its header runs Shiny code inside a report (Quarto documentation: Shiny); see Reports with Quarto and R Markdown.
plumber: an API from annotated functions
plumber turns ordinary R functions into HTTP endpoints. You write the functions in a file, mark each with a comment block that says on which verb and path it answers and how its result is to be rendered, and plumber reads the comments. plumb("plumber.R") parses the file: it evaluates it with source() in an environment of its own, so top-level code runs once, when the API is built, and turns each annotated function into an endpoint (or a filter) of a router, an R6 object of class Plumber. pr_run() serves the router with httpuv. Code at the top level of the file is where data is loaded and helpers are defined; the endpoint functions run, one request at a time, per request (plumber package documentation: Runtime). When a file is plumbed, plumber sets the working directory to the file’s directory while parsing and running it (a plumber 1.0.0 change), which is why source("scenario.R") in the file below finds the scenario next to it.
plumber 1.0.0 (2020-09-14) also added the pipe-friendly programmatic interface used in the later section: pr() makes an empty router and the pr_*() functions (pr_get(), pr_post(), pr_filter(), pr_set_serializer(), pr_run()) add to it. Both styles build the same router; the annotation style keeps the routes next to their code and generates the documentation, the programmatic style composes routers from functions and suits packages and tests.
The shelf API
The shelf API serves the scenario’s books and loans. Its project directory has the same shape as the dashboard’s:
~/shelf-api/
plumber.R
books.csv
loans.csv
scenario.R
plumber.R is shown as it was run. Like app.R, it is a file that plumb() reads, not a script for Rscript. It has seven endpoints, two filters and a router modifier. The comments above each function are the annotations; the first line of an endpoint’s comment becomes the summary in the OpenAPI document and the following lines its description (plumber 1.2.0 change).
# The shelf API: a file read by plumber::plumb(), not a script for Rscript.
source("scenario.R")
# Tidy the data once, when the API starts.
loans$member <- tools::toTitleCase(tolower(trimws(loans$member)))
loans <- loans[!is.na(loans$loan_date) & nzchar(loans$member), ]
returned_or_today <- loans$return_date
returned_or_today[is.na(returned_or_today)] <- shelf_today
loans$days_out <- as.integer(returned_or_today - loans$loan_date)
loans$overdue <- loans$days_out > loan_days
books$isbn_key <- normalise_isbn(books$isbn)
#* @apiTitle Shelf API
#* @apiDescription Books and loans of the shelf library.
#* @apiVersion 1.0.0
#* Router settings: plain JSON scalars, and no error details for clients
#* @plumber
function(pr) {
pr |>
plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
plumber::pr_set_error(function(req, res, err) {
message("shelf API error: ", conditionMessage(err))
res$status <- 500L
list(error = "internal error")
})
}
#* Let the dashboard call the API from its own origin
#* @filter cors
function(req, res) {
origin <- Sys.getenv("SHELF_ALLOWED_ORIGIN", "http://127.0.0.1:8765")
res$setHeader("Access-Control-Allow-Origin", origin)
if (identical(req$REQUEST_METHOD, "OPTIONS")) {
res$setHeader("Access-Control-Allow-Methods", "GET, POST")
res$setHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
res$status <- 204L
return(list())
}
plumber::forward()
}
#* Require an API key, except for the health check
#* @filter auth
function(req, res) {
key <- Sys.getenv("SHELF_API_KEY")
if (!nzchar(key) || identical(req$PATH_INFO, "/health") ||
identical(req$HTTP_X_API_KEY, key)) {
plumber::forward()
} else {
res$status <- 401L
list(error = "missing or wrong API key")
}
}
#* Is the service up?
#* @get /health
function() {
list(status = "ok", books = nrow(books), loans = nrow(loans))
}
#* All books, optionally of one genre
#* @param genre Only books of this genre (case-insensitive)
#* @get /books
function(genre = "") {
out <- books[, c("title", "isbn", "year", "genre", "pages")]
if (nzchar(genre)) {
out <- out[tolower(trimws(out$genre)) == tolower(genre), ]
}
out
}
#* The books as a CSV file
#* @get /export/books.csv
#* @serializer csv
function() {
books[, c("title", "isbn", "year")]
}
#* One book by ISBN (hyphens allowed)
#* @param isbn An ISBN-13
#* @get /books/<isbn>
function(isbn, res) {
hit <- books[books$isbn_key == normalise_isbn(isbn), ]
if (nrow(hit) == 0L) {
res$status <- 404L
return(list(error = paste("no book with ISBN", isbn)))
}
as.list(hit[1L, c("title", "author", "isbn", "year", "genre", "pages")])
}
#* Add a book (nothing is stored: the API validates and echoes)
#* @parser json
#* @post /books
function(req, res) {
b <- req$body
made <- tryCatch(
book(b$title, b$author, b$isbn, b$year, b$genre, b$pages),
error = \(e) e
)
if (inherits(made, "error")) {
res$status <- 400L
return(list(error = conditionMessage(made)))
}
res$status <- 201L
list(created = format(made))
}
#* Overdue loans, optionally of one member
#* @param member Member name, matched ignoring case
#* @get /loans/overdue
function(member = "") {
out <- loans[loans$overdue, c("loan_id", "member", "loan_date", "days_out")]
if (nzchar(member)) {
out <- out[tolower(out$member) == tolower(trimws(member)), ]
}
out$loan_date <- format(out$loan_date)
out
}
#* Loans per month
#* @get /stats/monthly
function() {
per_month <- table(format(loans$loan_date, "%Y-%m"))
data.frame(month = names(per_month), loans = as.integer(per_month))
}
Read from the top, the file does this:
-
It sources the scenario and tidies the loans once, at start-up, exactly as the dashboard does.
-
@apiTitle,@apiDescriptionand@apiVersionfill theinfoblock of the OpenAPI document. They are global annotations and can sit anywhere in the file. -
The block starting
#* @plumberis a router modifier: the function receives the router and must return it. Here it sets the default serializer to unboxed JSON (single values as JSON scalars, not one-element arrays; see the serializers section) and installs an error handler that logs the error withmessage()and gives the client a generic body. -
Two filters,
corsandauth, run in the order they appear in the file, before any endpoint. -
Each remaining block is an endpoint: a verb annotation (
@get,@post) with a path, a function, and optionally@param,@parserand@serializer./books/<isbn>has a dynamic path segment whose value arrives as the function argumentisbn;genreandmemberare query parameters, matched by name to function arguments, with defaults for when the client omits them.
The next script plumbs the file without running it and asks the router what it found. capture.output(print(pr))[[1L]] prints the router’s summary line (the full print draws a tree of the filters and endpoints). The endpoints field holds the endpoints (the __no-preempt__ group is the default one, explained under filters below), and the OpenAPI document is available as an R list from getApiSpec() without any server running.
# Run from the API directory: plumb() reads plumber.R and its annotations.
pr <- plumber::plumb("plumber.R")
capture.output(print(pr))[[1L]]
#> [1] "# Plumber router with 7 endpoints, 6 filters, and 0 sub-routers."
# Every endpoint knows its verbs, its path and its description.
endpoints <- pr$endpoints[["__no-preempt__"]]
data.frame(
verb = vapply(endpoints, \(e) paste(e$verbs, collapse = ","), character(1)),
path = vapply(endpoints, \(e) e$path, character(1))
)
#> verb path
#> 1 GET /health
#> 2 GET /books
#> 3 GET /export/books.csv
#> 4 GET /books/<isbn>
#> 5 POST /books
#> 6 GET /loans/overdue
#> 7 GET /stats/monthly
vapply(endpoints, \(e) e$comments[[1L]], character(1))
#> [1] "Is the service up?"
#> [2] "All books, optionally of one genre"
#> [3] "The books as a CSV file"
#> [4] "One book by ISBN (hyphens allowed)"
#> [5] "Add a book (nothing is stored: the API validates and echoes)"
#> [6] "Overdue loans, optionally of one member"
#> [7] "Loans per month"
# Filters run in file order before the endpoints.
vapply(pr$filters, \(f) f$name, character(1))
#> [1] "queryString" "body" "cookieParser" "sharedSecret" "cors"
#> [6] "auth"
# The OpenAPI document is built from the same annotations.
spec <- pr$getApiSpec()
spec$info$title
#> [1] "Shelf API"
names(spec$paths)
#> [1] "/health" "/books" "/export/books.csv"
#> [4] "/books/{isbn}" "/loans/overdue" "/stats/monthly"
names(spec$paths[["/books"]])
#> [1] "get" "post"
Seven endpoints and six filters: the file defines two filters, cors and auth, and plumber adds four of its own at the front (queryString, body, cookieParser and sharedSecret), which parse the query string, the body and the cookies, and compare the PLUMBER_SHARED_SECRET header with the option plumber.sharedSecret when one is set (plumber package documentation: options_plumber). The OpenAPI paths show the dynamic route in OpenAPI’s notation, /books/{isbn}, and /books has two operations, get and post.
Running and calling it
pr_run() is the function that starts the server. It blocks, as runApp() does, so the script starts it with callr (callr package documentation: r_bg), in a second process that also sets SHELF_API_KEY (a stand-in key for the test), waits for it, calls it with httr2 (httr2 package documentation: req_perform), repeats a few calls with the command-line curl, and stops it. The start-up lines are what the server printed on its standard error: the address and the interactive documentation. The same script then shows each kind of response: an open route, a rejected request, a query parameter, a path parameter, the handler’s own 404 and plumber’s, a 405 for a verb the route does not have, a POST that succeeds and one that the validator rejects, a malformed body, a CSV endpoint, the CORS preflight, and the OpenAPI document.
library(httr2)
# Run from the API directory: plumb() reads plumber.R from the current folder.
run_api <- function() {
Sys.setenv(SHELF_API_KEY = "demo-key")
plumber::plumb("plumber.R") |>
plumber::pr_run(host = "127.0.0.1", port = 8766L)
}
srv <- callr::r_bg(run_api)
on.exit(srv$kill(), add = TRUE)
# A request for a path of the API, never raising on 4xx or 5xx.
base <- "http://127.0.0.1:8766"
api <- function(..., query = list(), key = "demo-key") {
req <- request(base) |>
req_url_path_append(...) |>
req_url_query(!!!query) |>
req_error(is_error = \(r) FALSE)
if (is.null(key)) req else req_headers(req, `X-API-Key` = key)
}
# Perform a request; print the status and the start of the body.
show <- function(req, width = 110L) {
resp <- req_perform(req)
cat(resp_status(resp), substr(resp_body_string(resp), 1L, width), "\n")
invisible(resp)
}
# Wait until the API answers.
for (i in 1:150) {
up <- tryCatch(req_perform(api("health")), error = \(e) NULL)
if (!is.null(up)) break
Sys.sleep(0.2)
}
srv$read_error_lines()
#> [1] "Running plumber API at http://127.0.0.1:8766"
#> [2] "Running swagger Docs at http://127.0.0.1:8766/__docs__/"
# An open route, then a protected one without and with the key.
show(api("health", key = NULL))
#> 200 {"status":"ok","books":16,"loans":58}
show(api("books", key = NULL))
#> 401 {"error":"missing or wrong API key"}
show(api("books", query = list(genre = "poetry")))
#> 200 [{"title":"Garden of Small Fires","isbn":"978-0-00-500057-1","genre":"Poetry","pages":96},{"title":"Paper Moon
# Path parameters, and a 404 from the handler itself.
show(api("books", "978-0-306-40615-7"))
#> 200 {"title":"The Dispossessed","author":"Ursula K. Le Guin","isbn":"978-0-306-40615-7","year":1974,"genre":"Scien
show(api("books", "000"))
#> 404 {"error":"no book with ISBN 000"}
# A path no route matches: plumber's own 404 body.
show(api("nothing"))
#> 404 {"error":"404 - Resource Not Found"}
# A route that exists, with another verb: 405.
show(api("health") |> req_method("DELETE"))
#> 405 {"error":"405 - Method Not Allowed"}
# Query parameters and JSON data frames.
show(api("loans", "overdue", query = list(member = "Dev Patel")), width = 160L)
#> 200 [{"loan_id":"L018","member":"Dev Patel","loan_date":"2025-07-01","days_out":25},{"loan_id":"L040","member":"Dev Patel","loan_date":"2025-11-18","days_out":24},{
show(api("stats", "monthly"), width = 90L)
#> 200 [{"month":"2025-01","loans":4},{"month":"2025-02","loans":2},{"month":"2025-03","loans":3}
# A request body: valid, invalid (the validator speaks), malformed.
new_book <- list(
title = "Paper Moons", author = "Mara Quill", isbn = "978-0-306-40615-7",
year = 2020L, genre = "Poetry", pages = 96L
)
show(api("books") |> req_body_json(new_book))
#> 201 {"created":"Paper Moons (Mara Quill, 2020)"}
show(api("books") |> req_body_json(modifyList(new_book, list(isbn = "9"))))
#> 400 {"error":"invalid ISBN-13: 9"}
show(api("books") |> req_body_raw("{not json", type = "application/json"))
#> 500 {"error":"internal error"}
# The details stay in the server log, not in the response.
log <- character()
for (i in 1:50) {
log <- c(log, srv$read_error_lines())
if (any(grepl("shelf API error", log))) break
Sys.sleep(0.1)
}
log[[1L]]
#> [1] "shelf API error: lexical error: invalid string in json text."
# The serializer set per endpoint decides the content type.
resp <- req_perform(api("export", "books.csv"))
resp_content_type(resp)
#> [1] "text/csv"
head(strsplit(resp_body_string(resp), "\n")[[1L]], 3L)
#> [1] "title,isbn,year"
#> [2] "The Dispossessed,978-0-306-40615-7,1974"
#> [3] "Pride and Prejudice,978-0-14-143951-8,1813"
# The CORS preflight is answered before the key is checked.
pre <- req_perform(api("books", key = NULL) |> req_method("OPTIONS"))
resp_status(pre)
#> [1] 204
resp_header(pre, "Access-Control-Allow-Origin")
#> [1] "http://127.0.0.1:8765"
# The same checks with curl, printing only the status code.
status <- \(...) {
args <- c("-s", "-o", "/dev/null", "-w", "'%{http_code}'", ...)
system2("curl", args, stdout = TRUE)
}
status(paste0(base, "/health"))
#> [1] "200"
status(paste0(base, "/books"))
#> [1] "401"
status("-H", "'X-API-Key: demo-key'", paste0(base, "/books"))
#> [1] "200"
# The documentation page and the OpenAPI document are behind the filters too.
resp_status(req_perform(api("__docs__/", key = NULL)))
#> [1] 401
resp_status(req_perform(api("__docs__/")))
#> [1] 200
# The OpenAPI document that plumber builds from the annotations.
spec <- req_perform(api("openapi.json")) |> resp_body_json()
spec$info$title
#> [1] "Shelf API"
names(spec$paths)
#> [1] "/health" "/books" "/export/books.csv"
#> [4] "/books/{isbn}" "/loans/overdue" "/stats/monthly"
one <- spec$paths[["/books/{isbn}"]]
jsonlite::toJSON(one, auto_unbox = TRUE, pretty = TRUE)
#> {
#> "get": {
#> "operationId": "/books/{isbn}-GET",
#> "summary": "One book by ISBN (hyphens allowed)",
#> "description": "",
#> "responses": {
#> "200": {
#> "description": "OK"
#> },
#> "500": {
#> "description": "Internal Server Error",
#> "content": {
#> "application/json": {
#> "schema": {
#> "type": "string"
#> }
#> }
#> }
#> },
#> "default": {
#> "description": "Default response."
#> }
#> },
#> "parameters": [
#> {
#> "name": "isbn",
#> "description": "An ISBN-13",
#> "in": "path",
#> "required": true,
#> "schema": {
#> "type": "string"
#> }
#> }
#> ]
#> }
#> }
invisible(srv$kill())
srv$is_alive()
#> [1] FALSE
Several things to read in that output.
-
Status codes are yours to set. A handler sets
res$status <- 404Land returns a list; the serializer renders the list as the body.resis also where headers are set (res$setHeader()) and where a handler can return early. The statuses in the output are the handler’s (404 for a missing ISBN, 400 for an invalid book, 201 for a created one) except for the ones plumber produces itself. -
plumber’s own bodies are JSON too. An unknown path gives
{"error":"404 - Resource Not Found"}; a known path with the wrong verb gives{"error":"405 - Method Not Allowed"}(a 405 for a valid route requested with an invalid method comes from the experimental optionplumber.methodNotAllowed, added in plumber 1.1.0 and on by default). A handler error without a custom handler gives{"error":"500 - Internal server error"}, shown in the programmatic example below; the shelf API replaces it with{"error":"internal error"}for the malformed JSON body, and the details (lexical error: invalid string in json text) go to the server’s log, which is where they belong. Since plumber 1.3.0, error messages are not returned to the client by default. -
The filters ran in order. The preflight
OPTIONSrequest got its 204 and the CORS header without a key becausecorsis declared beforeauth; if the order were reversed, every browser preflight would be rejected with a 401 and the dashboard could not call the API. The request for/__docs__/without the key was a 401 too: filters protect the documentation page and the OpenAPI document along with the endpoints. -
The preflight and the headers matter only to browsers.
curland httr2 are not subject to cross-origin rules; a browser page served from one origin that calls an API on another sends anOPTIONSrequest first and refuses the response unless the headers allow it.
The numbers on the curl lines are the statuses, 200 for the open route, 401 for the protected route without the key and 200 again with it. curl is the usual way to try an API from a terminal, so the equivalent shell lines are, with the key in a variable so that it is not in the command history:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8766/health
curl -s -H "X-API-Key: $SHELF_API_KEY" 'http://127.0.0.1:8766/loans/overdue?member=Dev%20Patel'
Those two lines are fragments, not part of the scripts that ran: they show the shell form of calls that the script made from R. In an R session in the project directory the API is started with plumber::pr_run(plumber::plumb("plumber.R")), or, from a terminal, with Rscript -e 'plumber::pr_run(plumber::plumb("plumber.R"), port = 8766)'. pr_run() listens on host = "127.0.0.1" unless told otherwise, takes its default port from the option plumber.port (or the environment variable PLUMBER_PORT, which is how every plumber option can be set: upper case, with underscores), and with quiet = TRUE prints no start-up message (plumber package documentation: pr_run). Since plumber 1.3.0 a port must be between 1024 and 49151 and not a known unsafe one, or plumber throws an error.
The annotation reference
The annotations are documented in one article, which is the reference for this table (plumber package documentation: Annotations reference). A comment line starts with #* (or #', which the article advises against because roxygen2 also uses it), then @keyword and its content.
| Annotation | Meaning | Example |
|---|---|---|
|
makes the next function an endpoint for that verb ( |
|
|
documents a query, path or body parameter in the OpenAPI document; |
|
|
how the return value becomes a response body |
|
|
how a request body is parsed into |
|
|
makes the next function a filter |
|
|
this endpoint runs before the named filter |
|
|
the next function receives the router and returns it, modified |
|
|
serves a directory of static files |
|
|
global annotations that fill the OpenAPI |
|
|
tags and documents a response of one endpoint |
|
Two behaviours in the article are easy to trip on. Query parameters arrive as character strings and must be converted in the function (age <- as.integer(age)), while dynamic path segments with a type (<age:int>) are converted for you; a ?limit=5 reaches the function as "5". And a parameter name that appears in the query, the path and the body is passed only once, with query values first, then path, then body; the article recommends defining handlers with only req, res and ... when names can collide, and reading req$argsQuery, req$argsPath and req$argsBody explicitly. The programmatic script below prints those three for a request.
Serializers: from R value to response body
A serializer turns the value a handler returns into the bytes of the response and sets the Content-Type header. The default is serializer_json(); @serializer <alias> on an endpoint, or the serializer = argument of pr_get(), picks another, and pr_set_serializer() changes the router’s default, as the shelf API does. The aliases that annotations accept are the names registered in plumber 1.3.3, printed here by the package itself; registered_parsers() lists the same for request bodies.
# The aliases that @serializer and @parser accept in annotations.
plumber::registered_serializers()
#> [1] "agg_jpeg" "agg_png" "agg_tiff" "arrow_ipc_stream"
#> [5] "bmp" "cat" "contentType" "csv"
#> [9] "device" "excel" "feather" "format"
#> [13] "geojson" "html" "htmlwidget" "jpeg"
#> [17] "json" "null" "octet" "parquet"
#> [21] "pdf" "png" "print" "rds"
#> [25] "svg" "svglite" "text" "tiff"
#> [29] "tsv" "unboxedJSON" "yaml"
plumber::registered_parsers()
#> [1] "all" "arrow_ipc_stream" "csv" "excel"
#> [5] "feather" "form" "geojson" "json"
#> [9] "multi" "none" "octet" "parquet"
#> [13] "rds" "text" "tsv" "yaml"
| Annotation | Function | Response |
|---|---|---|
|
|
|
|
|
JSON in which length-one vectors become scalars ( |
|
|
a data frame as |
|
|
a string, as |
|
|
opens a graphics device before the handler runs and returns what was drawn; the handler just calls |
|
|
binary or text exchange formats; |
|
|
your own: a MIME type and a function that turns the value into text or bytes |
The unboxed-JSON choice deserves its own sentence, because it changes what clients see. R has no scalars, only vectors of length one, so jsonlite::toJSON(list(a = 5)) writes {"a":[5]}; plumber’s default keeps that ambiguity-free form so that an endpoint that sometimes returns one value and sometimes several always returns an array. Most JSON APIs return scalars as scalars, which is unboxedJSON, and a data frame is an array of records in both. Mixing them is allowed per endpoint: the shelf API sets unboxed as the default in its router modifier, and the programmatic API below does the same with pr_set_serializer(serializer_unboxed_json()). The as_attachment(value, filename) helper (plumber 1.0.0) marks a returned file as a download with a name of its own.
Programmatic usage: the same API without annotations
The pr_*() functions build a router without a file. The script below builds a small one, starts it on port 8767, and shows each of the parts that annotations hide: a filter (pr_filter() and forward()) that stamps every response with a header, a dynamic path segment and a query parameter that arrive as strings, the fields of the request object, a status and a header set through res, four serializers (CSV, HTML, a custom one made with serializer_content_type() and PNG), a JSON body parsed into req$body, an upload limit with options_plumber(), the default 500, 404 and 405 bodies, and the fact that pr_run(docs = FALSE) also removes the OpenAPI document. The server’s standard output is where plumber prints the error of a failing handler.
library(httr2)
source("scenario.R")
# The same kind of API, built with the pr_*() functions instead of annotations.
build_api <- function(books) {
library(plumber)
options_plumber(maxRequestSize = 200)
pr() |>
pr_set_serializer(serializer_unboxed_json()) |>
pr_filter("stamp", function(res) {
res$setHeader("X-Served-By", "shelf")
forward()
}) |>
pr_get("/boom", \() stop("the disk is on fire")) |>
pr_get("/echo/<what>", function(what, n = 1, req) {
list(
what = what, n_class = class(n),
method = req$REQUEST_METHOD, path = req$PATH_INFO,
query_string = req$QUERY_STRING,
argsQuery = req$argsQuery, argsPath = req$argsPath
)
}) |>
pr_get("/status", function(res) {
res$status <- 202L
res$setHeader("X-Shelf", "demo")
list(accepted = TRUE)
}) |>
pr_get("/books.csv", \() books[, c("title", "year")],
serializer = serializer_csv()) |>
pr_get("/books.html", \() {
paste0("<ul>", paste0("<li>", trimws(books$title), "</li>",
collapse = ""), "</ul>")
}, serializer = serializer_html()) |>
pr_get("/titles.txt", \() books["title"],
serializer = serializer_content_type(
"text/plain; charset=UTF-8",
\(val) paste(trimws(val$title), collapse = "\n")
)) |>
pr_get("/years.png", \() hist(books$year, main = "Publication years"),
serializer = serializer_png(width = 400, height = 300)) |>
pr_post("/total", function(req) list(total = sum(req$body$x))) |>
pr_run(host = "127.0.0.1", port = 8767L, docs = FALSE)
}
srv <- callr::r_bg(build_api, args = list(books = books))
on.exit(srv$kill(), add = TRUE)
base <- "http://127.0.0.1:8767"
api <- function(...) {
request(base) |>
req_url_path_append(...) |>
req_error(is_error = \(r) FALSE)
}
for (i in 1:150) {
up <- tryCatch(req_perform(api("status")), error = \(e) NULL)
if (!is.null(up)) break
Sys.sleep(0.2)
}
# What handlers receive: path and query parameters (as strings), and req.
resp <- api("echo", "abc") |>
req_url_query(n = 3, extra = "x") |>
req_perform()
str(resp_body_json(resp))
#> List of 7
#> $ what : chr "abc"
#> $ n_class : chr "character"
#> $ method : chr "GET"
#> $ path : chr "/echo/abc"
#> $ query_string: chr "?n=3&extra=x"
#> $ argsQuery :List of 2
#> ..$ n : chr "3"
#> ..$ extra: chr "x"
#> $ argsPath :List of 1
#> ..$ what: chr "abc"
# What handlers can set: the status and the headers; a filter ran first.
resp <- req_perform(api("status"))
resp_status(resp)
#> [1] 202
resp_header(resp, "X-Shelf")
#> [1] "demo"
resp_header(resp, "X-Served-By")
#> [1] "shelf"
# Serializers decide the content type of the response.
for (path in c("books.csv", "books.html", "titles.txt", "years.png")) {
resp <- req_perform(api(path))
cat(path, resp_status(resp), resp_content_type(resp), "\n")
}
#> books.csv 200 text/csv
#> books.html 200 text/html
#> titles.txt 200 text/plain
#> years.png 200 image/png
# The custom serializer wrote one title per line.
resp <- req_perform(api("titles.txt"))
head(strsplit(resp_body_string(resp), "\n")[[1L]], 2L)
#> [1] "The Dispossessed" "Pride and Prejudice"
# A PNG file starts with these four bytes.
resp <- req_perform(api("years.png"))
resp_body_raw(resp)[2:4] |> rawToChar()
#> [1] "PNG"
# Parsers decide how a request body arrives in req$body.
resp <- api("total") |>
req_body_json(list(x = c(1, 2, 3))) |>
req_perform()
resp_body_json(resp)
#> $total
#> [1] 6
#>
# options_plumber(maxRequestSize =) rejects a body above the limit (here 200
# bytes; the default, 0, means unlimited) before any handler runs.
resp <- api("total") |>
req_body_json(list(x = 1:500)) |>
req_perform()
cat(resp_status(resp), resp_body_string(resp), "\n")
#> 413 Maximum upload size exceeded
# An error in a handler is a 500 whose body hides the details; the details
# go to the server's log (its standard output).
resp <- req_perform(api("boom"))
cat(resp_status(resp), resp_body_string(resp), "\n")
#> 500 {"error":"500 - Internal server error"}
log <- character()
for (i in 1:50) {
log <- c(log, srv$read_output_lines())
if (any(grepl("disk", log))) break
Sys.sleep(0.1)
}
log
#> [1] "<simpleError in (function () stop(\"the disk is on fire\"))(): the disk is on fire>"
# No route: 404. Wrong verb on an existing route: 405.
resp <- req_perform(api("nothing"))
cat(resp_status(resp), resp_body_string(resp), "\n")
#> 404 {"error":"404 - Resource Not Found"}
resp <- req_perform(api("status") |> req_method("PUT"))
cat(resp_status(resp), resp_body_string(resp), "\n")
#> 405 {"error":"405 - Method Not Allowed"}
# pr_run(docs = FALSE) also removes the OpenAPI document.
resp_status(req_perform(api("openapi.json")))
#> [1] 404
invisible(srv$kill())
srv$is_alive()
#> [1] FALSE
Reading the output top to bottom:
-
The request object is an environment that follows the Rook specification.
req$QUERY_STRINGis the raw query,req$argsQueryandreq$argsPathare the parsed query and path values (both lists of strings, which is whyn_classis"character"even though the client sent3), andreq$REQUEST_METHODandreq$PATH_INFOare what they say. The full list of fields is a table in the routing article (plumber package documentation: Routing and Input); every header the client sent is anHTTP_field (req$HTTP_X_API_KEYforX-API-Key). -
resis the other environment. ItsstatusandsetHeader()are what the script used; returningresitself from an endpoint after settingres$bodybypasses serialization and sends the body as it is, with noContent-Type(the Rendering Output article calls this bypassing serialization). -
Filters run before endpoints, in the order they were added; the
X-Served-Byheader was set by a filter that the/statushandler knows nothing about. A filter either callsplumber::forward()to pass the request on, returns a value (which then becomes the response, asauthdoes for a 401), or throws an error. The article warns that the most common bug is a filter that forgetsforward(): the value of its last line becomes the response. -
The 413 shows
plumber.maxRequestSize. By default it is 0, which means unlimited; an API that accepts uploads should set a limit (it answers with the plain textMaximum upload size exceededrather than JSON). -
The 500 body hides the error and the server’s standard output shows it, as
<simpleError in ...>. Replacing the error handler, as the shelf API does withpr_set_error(), controls both. The 404 is the router’s default for a path with no endpoint and can be replaced withpr_set_404().
Filters in detail
By default every filter runs for every request, before routing. @preempt cors on an endpoint (or preempt in pr_get()) makes the endpoint run before the named filter; the endpoint’s position among the groups printed by pr$endpoints is why the shelf API’s endpoints sit in the group __no-preempt__. Mounting is the other structuring tool: pr |> pr_mount("/v1", other_router) serves another router below a path prefix, and each mounted router has its own environment unless one is shared (plumber package documentation: pr_mount). pr_hook() registers functions that run at fixed points of the request, such as preroute and postserialize, for logging and timing (plumber package documentation: pr_hook).
Request bodies and parsers
A POST body arrives raw in req$bodyRaw and parsed in req$body. The parser is chosen from the request’s Content-Type among the parsers allowed on the endpoint; plumber 1.0.0 added body parsing with the parsers json, form, multi (multipart, for uploads), csv, tsv, rds, text, octet, yaml, feather and none, and @parser json or pr_post(..., parsers = "json") restricts an endpoint to the ones you list. Each named field of the body is also passed to the handler as an argument if its name matches. The shelf API’s POST /books takes a JSON object and validates it with the scenario’s book() constructor, which stops with a message that the endpoint turns into a 400:
-
a well-formed book returns
201and the formatted title; -
a book with a bad ISBN returns
400andinvalid ISBN-13: 9, the validator’s own message; -
a body that is not JSON returns the router’s error handler’s
500, because the parse failure is an error before the handler is reached.
That last case is a place to improve a real API: a parse failure is the client’s mistake, so a router error handler that looks at the class of the condition could answer 400. The principle is the general one for an API: validate on the way in, turn validation failures into 4xx responses with a useful message, and let only genuine server faults be 5xx. Defensive Programming and Input Validation and Conditions and Error Handling cover the R side of that.
The OpenAPI document
plumber builds an OpenAPI 3 document (openapi: 3.0.3 in plumber 1.3.3) from the annotations and serves it at /openapi.json (and /openapi.yaml), with an interactive page at /__docs__/. In 11-run-api the document had the title and version of the @api* annotations, a path per endpoint with {isbn} for the dynamic segment, and for GET /books/{isbn} the summary from the first comment line, the isbn parameter taken from the function signature and @param, and generic 200, 500 and default responses. @response, @tag, @param name:type* and typed routes add more. Tools can read this document to generate client code, which is one reason to annotate carefully. Plumber 1.1.0 can set the document from a file path, and pr_set_api_spec() (or $setApiSpec()) takes a function that edits the generated specification before it is served (plumber package documentation: pr_set_api_spec). The docs interface defaults to Swagger UI (plumber.docs = TRUE means "swagger"); pr_set_docs() chooses another, such as RapiDoc or Redoc, or switches the page off (plumber package documentation: pr_set_docs).
The auto-generated page is a convenience for developers and a hint for attackers: it lists every endpoint and parameter. Production APIs normally run with pr_run(docs = FALSE), which, as the programmatic script showed, removes /openapi.json too; publish the document through a separate, deliberate route if clients need it.
Securing an API
plumber’s "Security" article is short and worth reading in full (plumber package documentation: Security). What follows applies it to the shelf API.
-
Network exposure.
pr_run()listens on127.0.0.1, which only the local machine can reach. Binding tohost = "0.0.0.0"exposes the API to the network (and in a container it is required); decide that on purpose and put a firewall or a reverse proxy in front. -
HTTPS. plumber does not implement HTTPS itself. Terminate TLS in a reverse proxy or the hosting platform (nginx, Apache, Caddy, a load balancer, Connect), redirect HTTP to HTTPS there, and consider HSTS. Never send an API key over plain HTTP outside localhost.
-
Authentication. The
authfilter of the shelf API is the smallest useful scheme: a key in a header, compared with a secret that comes from the environment (Sys.getenv("SHELF_API_KEY"), set in the service definition or an.Renvironfile outside version control), with401for a missing or wrong key. The key in the test script,demo-key, is a placeholder that exists only inside it. A single shared key authenticates a program, not a person, and cannot be revoked per client; for people or per-client revocation use the platform’s authentication (Connect, an identity-aware proxy) or OAuth, described in HTTP and Web APIs from the client’s side. Do not invent your own password hashing or token signing: use the platform’s, a maintained package such as sodium (which plumber itself uses for encrypted session cookies), or the proxy’s. -
Cookies and sessions. If you use cookies for anything security-sensitive, the article’s rules apply: the client can alter or forge a cookie, so keep the data on the server and the cookie an opaque id, or use the encrypted session cookie (
pr_cookie()), setHttpOnlyand, on HTTPS,Secure. -
CORS. Browsers refuse cross-origin calls unless the response allows them. The article’s one-line filter sends
Access-Control-Allow-Origin: *, which opens an API to every web page; the shelf API names the dashboard’s origin instead and answers the preflight. Allow the origins you know. -
Input. Everything the client sends is untrusted. Cap the sizes of bodies (
plumber.maxRequestSize) and parameters, as the article’s plot example does with the number of points, because one expensive request is a denial of service in a single-threaded server; never build file paths or SQL from input (see Databases with DBI for parameterised queries); escape anything placed in HTML. -
Rate limiting. plumber has none. Put it where the traffic arrives: the reverse proxy, the API gateway or the hosting platform. An in-process counter does not survive a second R process.
-
Shared secret. When a trusted proxy fronts the API,
plumber.sharedSecretmakes plumber reject any request that lacks the matchingPLUMBER_SHARED_SECRETheader (with a 400), so that a direct request that bypasses the proxy is refused. -
Documentation and errors. Turn the docs off (
docs = FALSE) and keep error details in the log, as the shelf API does.
One thread, no memory: concurrency and state
Because R is single-threaded, one slow request delays every other. The "Runtime" article’s advice has three parts. Keep endpoints fast. Do long jobs elsewhere: a queue and a worker, or asynchronous endpoints, since plumber 1.0.0 supports promises in endpoints, filters and hooks, so an endpoint can return a future or a mirai promise and let the router serve other requests meanwhile (Parallel and Asynchronous Computing). And run several R processes behind a load balancer, which works only if the API is stateless: no request may rely on what an earlier request left in memory, because the next one may be served by another process. The same article describes the places for state: memory (acceptable for read-only data loaded at start-up, as the shelf API’s books and loans, but not for anything that changes), files (not safe under concurrent writers, and some platforms discard them on redeploy), cookies, and a database or other service outside the process, which is the answer for anything shared. That is why POST /books in the shelf API validates and echoes and does not store anything.
Hosting a plumber API
An API run from an interactive session is a development setup. The "Hosting" article lists what to use instead (plumber package documentation: Hosting): Posit Connect, which runs and scales R processes for you (plumber APIs are among the content types it publishes with rsconnect::deployApp()); Docker, with the rstudio/plumber image whose entry point plumbs the file named as its first argument and serves it on port 8000; and on a plain Linux server a service manager, either pm2 (a Node.js process manager that the article adapts) or systemd. Two fragments follow, neither of them run for this page (no image was pulled and no service was installed). The first is the article’s custom image, adapted to the shelf API: it copies the project in, and CMD names the file to plumb.
FROM rstudio/plumber
COPY shelf-api /app
CMD ["/app/plumber.R"]
The second is a systemd unit in the shape of the article’s example. The service’s environment, not the file, carries the key; Restart=on-abnormal restarts the process after a crash; host = "127.0.0.1" keeps the API private to a reverse proxy on the same machine, which terminates TLS.
[Unit]
Description=Shelf API
[Service]
EnvironmentFile=/etc/shelf-api.env
WorkingDirectory=/srv/shelf-api
ExecStart=<path to Rscript> -e "plumber::pr_run(plumber::plumb('plumber.R'), host = '127.0.0.1', port = 8766, docs = FALSE)"
Restart=on-abnormal
[Install]
WantedBy=multi-user.target
/etc/shelf-api.env would contain one line, SHELF_API_KEY= followed by the key, readable only by the service’s user. Docker, pm2 and systemd all give the one thing the article stresses a development session cannot: the API starts again when the machine restarts and when the process dies. For Docker as a platform see Docker.
plumber2, pre-1.0
plumber2 is a complete rewrite of plumber by Thomas Lin Pedersen (Posit), on CRAN since September 2025; version 0.2.0 (2026-01-20) is the one installed for this page. It is pre-1.0: its own site says that it diverts from plumber "in key areas, resulting in API incompatibility between the two packages", and, as for any 0.x package, the interface can still change between releases. For that reason the examples on this page use plumber 1.3.3, the stable package, and plumber2 is described here from its documentation without any code being run (plumber2 package documentation).
What changes, according to the plumber2 site and its changelog:
-
Annotation parsing is built on roxygen2, so tags follow the roxygen conventions: multi-line tags, the first untagged line as the title and later lines as the description, and richer types for inputs and outputs (nested objects, defaults, enums). Inputs are type checked and defaults are filled in before the handler runs.
-
Path, query and body parameters are separated. Only path parameters are direct arguments of the handler; query and body values arrive through arguments named
queryandbody, documented with different tags. The query-string-as-function-argument convention of plumber 1.x does not carry over. -
A different foundation. plumber2 is built on the fiery web server framework, the routr routing package and reqres for requests and responses (all three are in its
Imports), and its programmatic builder isapi()with theapi_get(),api_post()andapi_run()family instead ofpr(),pr_get()andpr_run(). -
Several serializers per endpoint, chosen by content negotiation on the
Acceptheader, an@asynctag that turns a handler into an asynchronous one, authentication through the fireproof package (new in 0.2.0), OpenTelemetry instrumentation, and the ability to mount a Shiny app in the same server (api_shiny()).
Moving a plumber 1.x API to plumber2 therefore means editing it, not just installing a new package; the plumber2 site has a migration article (plumber2 package documentation: Migration). Until plumber2 reaches 1.0, new code that has to be stable for years should use plumber 1.x, and code that wants the new features should treat the interface as provisional. plumber 1.x remains the stable package on CRAN (1.3.3, 2026-01-28).
Shiny or plumber?
The choice usually follows the audience. If the consumer is a person who needs to look at data and change a parameter, it is Shiny; if the consumer is a program, it is plumber. The two combine naturally. A dashboard can call an API with httr2 (the shelf dashboard could request /loans/overdue instead of reading loans.csv, with the key coming from Sys.getenv("SHELF_API_KEY") and the call made inside an ExtendedTask or a cached reactive), and an API can expose the model that an app’s users have been tuning. What to avoid is using a Shiny app as an interface for other programs (its WebSocket protocol is not designed as one) and using plumber to draw an interface. A Quarto or R Markdown document can contain either’s output, and a Shiny app can live inside a Quarto document; both are in Reports with Quarto and R Markdown.
The Python equivalent: FastAPI
The Python page on the same topic is Serving Data Science & AI, which serves a pandas aggregation, a chart, a model and a vector search from FastAPI, with the same attention to where slow work runs. The two frameworks do the same job in different idioms.
| plumber 1.x | FastAPI | |
|---|---|---|
Route |
a comment annotation, |
a decorator, |
Parameters |
matched by name to function arguments, as character strings unless the route segment is typed; no validation unless you write it |
declared with Python type annotations and converted and validated for you; a bad value is answered with a 422 and a structured error |
Request body |
parsed by a content-type parser into |
parsed and validated against a Pydantic model |
Response |
a serializer renders the return value ( |
the return value is converted to JSON according to the return type or |
Documentation |
OpenAPI document and Swagger UI generated from the comments ( |
OpenAPI document and interactive docs generated from the code ( |
Concurrency |
one thread per R process; scale by running processes; promises for asynchronous work |
|
Middleware |
filters, in file order |
middleware and dependencies |
The practical difference for the author is where the contract lives. In FastAPI it is in the types of the function, so the code is the schema; in plumber 1.x it is split between the function and the comments, and values must be validated by hand (as POST /books does with the scenario’s book() constructor). plumber2’s typed annotations move R toward the FastAPI model. The security advice is the same in both: terminate TLS in front, keep secrets in the environment, validate input, bound the work of a request.
Pitfalls
-
Forgetting that
app.Randplumber.Rrun from their own directory.runApp(dir)sets the working directory to the app directory, andplumb()sets it to the file’s directory; code that reads a path relative to where the author happened to start R fails in the other deployment. Keep data next to the file, as the shelf examples do, or usesystem.file()for a package. -
Reading
input$xoutsidereactive(),render*()orobserve(). The errorCan't access reactive value 'x' outside of reactive consumeris Shiny telling you the code is in the wrong place; wrap it, or useisolate()if you want the value once. -
Writing to
input. It is read-only; useupdate*()functions to change an input from the server and areactiveVal()for server-side state. -
Putting side effects in a
reactive(). A reactive is for values and may not run when you expect. Side effects go inobserve()orobserveEvent(). -
A
reactiveVal()set from one observer to be read by another. That is a reactive expression written by hand; the dependency hides from the graph. Prefer areactive(). -
observeEvent()that does not fire on load. AnactionButton()is 0 at the start and 0 is falsy;ignoreNULL = FALSEchanges that, but the usual intent is that it does not run. -
A top-level assignment in the server function that is never reactive.
x <- input$aat the top of the server function raises the outside-a-consumer error; the right form isx <- reactive(input$a)andx(). -
Heavy work at the top of the server function. It runs for every session. Load data once at the top of
app.Ror inglobal.R, and cache expensive results withbindCache(). -
Assuming a hidden output ran. Outputs in a tab that was never shown are suspended;
outputOptions(output, "x", suspendWhenHidden = FALSE)is the opt-out. -
Leaving
reactlog,devmode()ordocs = TRUEon in production. The first exposes code and leaks memory; the second prints developer messages; the third publishes your API’s description. -
A plumber filter that does not call
forward(). The filter’s last value becomes the response, and the endpoints never run. Check every branch. -
Query parameters taken for numbers.
?limit=5is"5". Convert and check it (as.integer(), thenis.na()), or type the route segment. -
Returning data frames and expecting scalars. The default JSON serializer boxes length-one values into arrays; choose
unboxedJSONfor scalars. -
State in memory or on disk. It disappears when a process restarts and diverges when there are several. Keep state in a database.
-
Binding to
0.0.0.0on a laptop, or serving over plain HTTP across a network. Both turn a development server into a public one. -
Blocking the single thread. A two-second computation makes the server unreachable for two seconds, for everyone. Make it fast, make it asynchronous, or move it out.
Version notes
The Shiny rows are from the package’s NEWS (Shiny NEWS); the plumber rows are from its changelog (plumber package documentation: Changelog); plumber2 rows are from its changelog (plumber2 package documentation: Changelog). Only the entries relevant to this page are listed.
| Version | Change |
|---|---|
shiny 0.11 |
Bootstrap 3 replaces Bootstrap 2; |
shiny 1.4.0 |
the |
shiny 1.5.0 |
|
shiny 1.6.0 |
|
shiny 1.7.0 |
Bootstrap 5 support through |
shiny 1.7.2 |
|
shiny 1.8.1 |
|
shiny 1.9.0 |
|
shiny 1.12.0 |
OpenTelemetry support through the otel package |
shiny 1.13.0 |
interactive breakpoints with Ark (Positron) |
shiny 1.14.0 |
|
plumber 1.0.0 (2020-09-14) |
the pipe-friendly |
plumber 1.1.0 |
|
plumber 1.2.0 |
the first comment line becomes the OpenAPI |
plumber 1.3.0 |
the port can be set by an environment variable (valid ports 1024 to 49151); ragg and svglite devices; error messages are not returned to the client by default; |
plumber 1.3.1 to 1.3.3 |
Arrow IPC streams; |
plumber2 0.1.0 (2025-09-22) |
first CRAN release; pre-1.0 |
plumber2 0.2.0 (2026-01-20) |
authentication with fireproof, |
In shiny 1.14.0 a page built with fluidPage() and no theme still brings Bootstrap 3.4.1, as the first script printed; Bootstrap 5 is opt-in, through bslib::bs_theme(version = 5) or the bslib page functions such as page_sidebar(), and bslib 0.12.0 is a hard dependency of shiny 1.14.0.
R itself has no change relevant to this page in R 4.6.1; the web frameworks are packages and move on their own schedule. The development version of Shiny (after 1.14.0) already lists, in its NEWS, a fix to dateInput() with a disabled-dates format, an agent skill shipped in the package and the requirement of the watcher package; none of that is released.
Where older tutorials differ
Most tutorials on Shiny and plumber were written before moduleServer, bindEvent(), bslib and plumber 1.0.0. They still run, mostly, and are a map of what replaced what.
| Older tutorial | Today | Why | Stable in |
|---|---|---|---|
|
|
easier to read; only these modules work with |
shiny 1.5.0 |
|
the server function and the UI object, as the last expressions of |
both functions are marked superseded in their help pages and are no longer required |
current |
|
one |
a single file is easier to copy and to deploy; |
shiny 1.5.0 |
|
|
one mechanism for all, and it combines with |
shiny 1.6.0 |
|
|
Bootstrap 5 and component-level layout |
shiny 1.7.0 |
|
|
keeps the starting session responsive |
shiny 1.8.1 |
|
|
the shiny versions are deprecated and forward to DT |
shiny 1.8.1 |
|
|
rsconnect is the package that deploys to shinyapps.io and Connect |
rsconnect |
Shiny Server Pro as the commercial route |
Posit Connect |
Posit stopped selling Shiny Server Pro to new customers |
current |
|
|
the |
plumber 1.0.0 |
|
|
the shorthands are deprecated |
plumber 1.0.0 |
|
|
one place for the default |
plumber 1.0.0 |
|
|
renamed to snake case |
plumber 1.0.0 |
swagger at |
|
OpenAPI 3 and a choice of interface |
plumber 1.0.0 |
|
explicit |
query values are strings |
always |
CORS filter with |
a named origin from the environment, and a preflight answer |
|
always |
A Docker image built |
|
the image is now maintained under |
plumber 1.0.0 |
The programmatic-usage article describes the pr_*() functions as built on the same underlying R6 objects as the annotation style, so code written with the methods of the Plumber class ($run(), $handle(), $mount()) keeps working, and the functions are the documented way (plumber package documentation: Programmatic Usage).
Where to go next
-
Mastering Shiny (https://mastering-shiny.org/), a community book that is the usual long-form companion to the official Shiny documentation, and the plumber and Shiny articles listed under References. HTTP and Web APIs for the client side: httr2 requests, errors, retries, throttling and mocking, written against a plumber server like the one here.
-
Serialization: RDS, RData and JSON for what
jsonlitedoes with data frames,NAandNULLon the way into and out of an API. -
Reports with Quarto and R Markdown for
server: shinydocuments and for scheduled reports that replace a dashboard. -
Parallel and Asynchronous Computing for mirai, future and promises, the machinery behind
ExtendedTaskand asynchronous plumber endpoints. -
Databases with DBI for the connection pool and parameterised queries that an app or API needs.
-
Testing with testthat for the expectations used with
testServer(). -
Reproducible Environments with renv for the lockfile a deployment depends on.
-
Creating Packages and Documentation with Rd, roxygen2 and Vignettes for putting an app or an API in a package.
-
Scripts and the Command Line for
Rscriptas the entry point of a service. -
Serving Data Science & AI and Docker for the Python equivalent and the container platform.
References
-
Shiny documentation: Get started; Hosting and deployment; reference index
-
Shiny articles: Reactivity, an overview; Execution scheduling; Modules
-
Shiny reference pages: shinyApp, runApp, startApp, loadSupport, shinyAppTemplate, fluidPage, sidebarLayout, actionButton, selectInput, sliderInput, dateRangeInput, textInput, renderPrint and renderText, renderTable, renderPlot, renderUI, htmlOutput and uiOutput, session, onStop, outputOptions
-
Shiny reactivity: reactive, observeEvent and eventReactive, bindEvent, bindCache, isolate, req, validate and need, ExtendedTask, reactlog, devmode, shinyOptions
-
Shiny modules and testing: NS, moduleServer, testServer, runTests; shinytest2 package documentation
-
Deployment: rsconnect package documentation: deployApp; Rocker Project: shiny images; ShinyProxy; Quarto documentation: Shiny
-
plumber package documentation: articles Quickstart, Routing and Input, Rendering Output, Annotations reference, Runtime, Security, Hosting, Programmatic Usage; Changelog
-
plumber reference pages: plumb, pr_run, serializers, options_plumber, pr_hook, pr_mount, pr_set_docs, pr_set_api_spec
-
plumber2 (pre-1.0): plumber2 package documentation, Migration, Changelog
-
httpuv package documentation; callr package documentation: r_bg; httr2 package documentation: req_perform; mirai package documentation; promises package documentation; reactlog package documentation; pool package documentation; Shiny for Python documentation
-
R documentation: startDynamicHelp; R documentation: Sys.getenv