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, curl

What you write

a ui (HTML built with R functions) and a server function (reactive code)

plain R functions with #* comment annotations, or pr_get() calls

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

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"

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.R is the single-file form and the one to start with: the file defines ui and server (or builds the app inline) and ends with shinyApp(ui, server). It is a file that shiny::runApp() reads rather than a script that is run to completion: Rscript app.R happens 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.R and server.R are the older two-file form. ui.R ends with the UI object, server.R with the server function, and an optional global.R is 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 .R file of an R/ directory in alphabetical order, after global.R if 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.R in R/ or options(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 in img(src = "logo.png").

  • shinyAppTemplate() writes a starting project with app.R, an example in R/, a module and a tests/ 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

textInput(id, label, value = "")

a character string, "" until the user types

updates on change; updateOn = "blur" waits for the user to leave the field

selectInput(id, label, choices, selected, multiple)

a character vector; NULL when multiple = TRUE and nothing is selected

choices can be a named vector (names shown, values returned) or a list of groups

sliderInput(id, label, min, max, value)

a number, a date or a date-time, or a vector of two for a range

a length-two value makes a range slider

dateRangeInput(id, label, start, end)

a Date vector of length 2

format, min and max limit the choice

actionButton(id, label)

an integer of class shinyActionButtonValue, 0 until the first click

0 counts as "falsy": observeEvent() and eventReactive() do not run on the initial value, and req() stops

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

textOutput("summary")

renderText({...})

a character string (Shiny documentation: renderPrint and renderText); renderPrint() captures printed output instead

tableOutput("table")

renderTable({...})

a data frame or matrix as a plain HTML table (Shiny documentation: renderTable); DT::renderDT() is the interactive version

plotOutput("plot")

renderPlot({...})

whatever the block draws, as an image sized to the output container (Shiny documentation: renderPlot)

uiOutput("controls")

renderUI({...})

HTML built on the server, for inputs and layout that depend on other inputs (Shiny documentation: htmlOutput and uiOutput)

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'

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$member is one; so are the values of reactiveVal() and reactiveValues().

  • 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() or observeEvent(), and every output$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$n depends on input$n only while input$show is 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"

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

reactive({...})

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)

observe({...})

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 reactiveVal()

observeEvent(eventExpr, handlerExpr)

endpoint

only eventExpr, never what the handler reads

"when this button is clicked, do that"

eventReactive(eventExpr, valueExpr)

conductor

only eventExpr

a value that is recomputed on request, as for a "Run" button

bindEvent(x, ...)

modifier on a reactive(), observe() or render*()

only the expressions in ...

the same as the two above, but also for render functions and combinable with bindCache()

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"

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, creates ns <- NS(id), and wraps every input and output id in ns(): ns("table") becomes overdue-table when id is "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 calls moduleServer(id, function(input, output, session) {...}). Inside, input$table and output$table refer 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, not loans()), 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"

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

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)

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).

flowchart LR subgraph sources["Reactive sources: the input object"] member["input$member"] months["input$months"] end subgraph conductors["Reactive conductors: reactive expressions"] sel["loans_sel()"] over["overdue()"] end subgraph endpoints["Reactive endpoints: observers created by render functions"] plot["output$plot"] table["output$table (inside the loanTable module)"] summary["output$summary"] end member -- "invalidates" --> sel months -- "invalidates" --> sel months -- "invalidates" --> plot sel -- "invalidates" --> over sel -- "invalidates" --> plot sel -- "invalidates" --> summary over -- "invalidates" --> table over -- "invalidates" --> summary lazy["Lazy by design: a conductor runs only when an endpoint reads it, and an output hidden in the browser (suspendWhenHidden) is not recomputed until it is shown again"] lazy -.- endpoints

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,"

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)

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

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 under R/, 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, session and everything defined in it, such as loans_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. app can be a server function, a module function (with args), 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 broken ui; 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 many testServer() 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

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 rsconnect and Posit runs it

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 runApp() on 0.0.0.0, or the community rocker/shiny image, which is based on rocker/r-ver and has Shiny Server installed and listening on port 3838

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 loans object 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: shiny in 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:

  1. It sources the scenario and tidies the loans once, at start-up, exactly as the dashboard does.

  2. @apiTitle, @apiDescription and @apiVersion fill the info block of the OpenAPI document. They are global annotations and can sit anywhere in the file.

  3. The block starting #* @plumber is 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 with message() and gives the client a generic body.

  4. Two filters, cors and auth, run in the order they appear in the file, before any endpoint.

  5. Each remaining block is an endpoint: a verb annotation (@get, @post) with a path, a function, and optionally @param, @parser and @serializer. /books/<isbn> has a dynamic path segment whose value arrives as the function argument isbn; genre and member are 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 <- 404L and returns a list; the serializer renders the list as the body. res is 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 option plumber.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 OPTIONS request got its 204 and the CORS header without a key because cors is declared before auth; 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. curl and httr2 are not subject to cross-origin rules; a browser page served from one origin that calls an API on another sends an OPTIONS request 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

@get, @post, @put, @delete, @head, @options, @patch, @use

makes the next function an endpoint for that verb (@use for any verb) and path; several may be stacked on one function; <name> in the path is a dynamic segment, <name:int> a typed one

#* @get /books/<isbn>

@param name:type description

documents a query, path or body parameter in the OpenAPI document; type is one of bool, dbl, int, chr, list, file and others, [type] means an array, * after the type means required

#* @param genre:chr* Genre

@serializer alias [args]

how the return value becomes a response body

#* @serializer csv

@parser alias [args]

how a request body is parsed into req$body; may be repeated

#* @parser json

@filter name

makes the next function a filter

#* @filter auth

@preempt filter

this endpoint runs before the named filter

#* @preempt cors

@plumber

the next function receives the router and returns it, modified

#* @plumber

@assets dir [path]

serves a directory of static files

#* @assets ./www /static

@apiTitle, @apiDescription, @apiVersion, @apiTOS, @apiContact, @apiLicense, @apiTag

global annotations that fill the OpenAPI info and tags

#* @apiTitle Shelf API

@tag, @response

tags and documents a response of one endpoint

#* @response 404 No such book

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

@serializer json (default)

serializer_json()

application/json; a length-one vector becomes a one-element array

@serializer unboxedJSON

serializer_unboxed_json()

JSON in which length-one vectors become scalars (auto_unbox = TRUE); most APIs want this

@serializer csv, tsv

serializer_csv(), serializer_tsv()

a data frame as text/csv; charset=UTF-8 (new in plumber 1.0.0)

@serializer html, text

serializer_html(), serializer_text()

a string, as text/html or text/plain

@serializer png, jpeg, svg, pdf (also bmp, tiff)

serializer_png(width, height) and friends

opens a graphics device before the handler runs and returns what was drawn; the handler just calls plot() or hist() (plumber 1.3.0 added agg_png, svglite and the other ragg and svglite devices)

@serializer rds, yaml, feather, parquet

serializer_rds() and friends

binary or text exchange formats; feather and parquet need the arrow package

@serializer contentType

serializer_content_type(type, serialize_fn)

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_STRING is the raw query, req$argsQuery and req$argsPath are the parsed query and path values (both lists of strings, which is why n_class is "character" even though the client sent 3), and req$REQUEST_METHOD and req$PATH_INFO are 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 an HTTP_ field (req$HTTP_X_API_KEY for X-API-Key).

  • res is the other environment. Its status and setHeader() are what the script used; returning res itself from an endpoint after setting res$body bypasses serialization and sends the body as it is, with no Content-Type (the Rendering Output article calls this bypassing serialization).

  • Filters run before endpoints, in the order they were added; the X-Served-By header was set by a filter that the /status handler knows nothing about. A filter either calls plumber::forward() to pass the request on, returns a value (which then becomes the response, as auth does for a 401), or throws an error. The article warns that the most common bug is a filter that forgets forward(): 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 text Maximum upload size exceeded rather 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 with pr_set_error(), controls both. The 404 is the router’s default for a path with no endpoint and can be replaced with pr_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 201 and the formatted title;

  • a book with a bad ISBN returns 400 and invalid 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 on 127.0.0.1, which only the local machine can reach. Binding to host = "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 auth filter 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 .Renviron file outside version control), with 401 for 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()), set HttpOnly and, 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.sharedSecret makes plumber reject any request that lacks the matching PLUMBER_SHARED_SECRET header (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 query and body, 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 is api() with the api_get(), api_post() and api_run() family instead of pr(), pr_get() and pr_run().

  • Several serializers per endpoint, chosen by content negotiation on the Accept header, an @async tag 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, #* @get /books/<isbn>, above a plain function

a decorator, @app.get("/books/{isbn}"), on a function

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 req$body and passed field by field

parsed and validated against a Pydantic model

Response

a serializer renders the return value (@serializer unboxedJSON)

the return value is converted to JSON according to the return type or response_model

Documentation

OpenAPI document and Swagger UI generated from the comments (/openapi.json, /__docs__/)

OpenAPI document and interactive docs generated from the code (/openapi.json, /docs)

Concurrency

one thread per R process; scale by running processes; promises for asynchronous work

async def on an event loop, blocking def functions in a thread pool, workers for heavy jobs

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.R and plumber.R run from their own directory. runApp(dir) sets the working directory to the app directory, and plumb() 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 use system.file() for a package.

  • Reading input$x outside reactive(), render*() or observe(). The error Can't access reactive value 'x' outside of reactive consumer is Shiny telling you the code is in the wrong place; wrap it, or use isolate() if you want the value once.

  • Writing to input. It is read-only; use update*() functions to change an input from the server and a reactiveVal() 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 in observe() or observeEvent().

  • 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 a reactive().

  • observeEvent() that does not fire on load. An actionButton() is 0 at the start and 0 is falsy; ignoreNULL = FALSE changes 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$a at the top of the server function raises the outside-a-consumer error; the right form is x <- reactive(input$a) and x().

  • Heavy work at the top of the server function. It runs for every session. Load data once at the top of app.R or in global.R, and cache expensive results with bindCache().

  • 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() or docs = TRUE on 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=5 is "5". Convert and check it (as.integer(), then is.na()), or type the route segment.

  • Returning data frames and expecting scalars. The default JSON serializer boxes length-one values into arrays; choose unboxedJSON for 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.0 on 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; observeEvent() and eventReactive() are added

shiny 1.4.0

the shiny.autoload.r option, which sources the files of an R/ directory, is added (opt-in)

shiny 1.5.0

moduleServer() and testServer() are added; shinyAppTemplate() and runTests(); the R/ directory is sourced by default (loadSupport()); callModule() is no longer the recommended form

shiny 1.6.0

bindEvent() and bindCache() are added; theme arguments accept bslib::bs_theme() objects (Bootstrap 4 opt-in)

shiny 1.7.0

Bootstrap 5 support through bslib::bs_theme(version = 5)

shiny 1.7.2

renderPlot() uses ragg::agg_png() when ragg is installed

shiny 1.8.1

ExtendedTask is added (with bslib::input_task_button()); a JavaScript error dialog for devmode(); renderDataTable() and dataTableOutput() are officially deprecated in favour of DT

shiny 1.9.0

useBusyIndicators()

shiny 1.12.0

OpenTelemetry support through the otel package

shiny 1.13.0

interactive breakpoints with Ark (Positron)

shiny 1.14.0

startApp() (non-blocking), session$destroy() and onDestroy() on module sessions, the enabled argument of downloadButton(), X-Content-Type-Options: nosniff replaces the legacy X-UA-Compatible header, and loading shiny no longer creates .Random.seed

plumber 1.0.0 (2020-09-14)

the pipe-friendly pr_*() interface and pr(); promises in endpoints, filters and hooks; request body parsing and the parser_*() functions; serializer_csv(), serializer_text(), the graphics-device serializers and others; @plumber router modifiers; OpenAPI 3 and the docs at /__docs__/; the working directory is set to the plumbed file’s directory; the shorthand serializers @json, @png and others are deprecated in favour of @serializer; the R6 class is named Plumber

plumber 1.1.0

plumber.trailingSlash; the experimental plumber.methodNotAllowed (405); quiet, docs and debug arguments of pr_run(); an endpoint-error response is always JSON

plumber 1.2.0

the first comment line becomes the OpenAPI summary, later lines the description; GeoJSON, octet-stream and Parquet serializers; the path argument of pr_cookie()

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; operationId in the OpenAPI output; Excel serializer and parser

plumber 1.3.1 to 1.3.3

Arrow IPC streams; pr_run() honours apiPath for the docs; relative redirects from /__swagger__/ to /__docs__/ (1.3.3, 2026-01-28)

plumber2 0.1.0 (2025-09-22)

first CRAN release; pre-1.0

plumber2 0.2.0 (2026-01-20)

authentication with fireproof, api_report(), OpenTelemetry instrumentation, the @datastore tag

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

callModule(counterServer, "left") with a server function (input, output, session)

moduleServer("left", function(input, output, session) {...}) inside counterServer("left")

easier to read; only these modules work with testServer()

shiny 1.5.0

shinyServer(function(input, output) {...}) and shinyUI(fluidPage(...))

the server function and the UI object, as the last expressions of server.R and ui.R, or shinyApp(ui, server) in app.R

both functions are marked superseded in their help pages and are no longer required

current

ui.R and server.R as the only app form; runApp("dir")

one app.R, helpers in R/, data in the folder

a single file is easier to copy and to deploy; R/ is sourced automatically

shiny 1.5.0

observeEvent() everywhere, isolate() inside observe() to simulate an event

bindEvent() on a reactive(), an observe() or a render function

one mechanism for all, and it combines with bindCache()

shiny 1.6.0

fluidPage() with Bootstrap 3 only; shinythemes for appearance

bslib::page_sidebar(), bslib::bs_theme(), cards and value boxes

Bootstrap 5 and component-level layout

shiny 1.7.0

future plus promises for a long click handler, which blocks the same session

ExtendedTask with a mirai or future task

keeps the starting session responsive

shiny 1.8.1

renderDataTable() and dataTableOutput()

DT::renderDT() and DT::DTOutput()

the shiny versions are deprecated and forward to DT

shiny 1.8.1

shinyapps::deployApp() and shinyapps::setAccountInfo()

rsconnect::deployApp() and rsconnect::setAccountInfo()

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

plumber::plumb("plumber.R")$run(port = 8000) and r <- plumber$new(); r$handle("GET", "/x", f)

pr_run(plumb("plumber.R")), pr() |> pr_get("/x", f) |> pr_run()

the pr_*() functions are pipe friendly; the R6 plumber class is renamed Plumber

plumber 1.0.0

#* @json, #* @png, #* @html shorthand

#* @serializer json, #* @serializer png, #* @serializer html

the shorthands are deprecated

plumber 1.0.0

#* @serializer unboxedJSON repeated on every endpoint

pr_set_serializer(serializer_unboxed_json()) once, or a @plumber modifier

one place for the default

plumber 1.0.0

addSerializer(), sessionCookie(), randomCookieKey()

register_serializer(), session_cookie(), random_cookie_key()

renamed to snake case

plumber 1.0.0

swagger at /__swagger__/ and plumber$run(swagger = TRUE)

/__docs__/ and pr_run(docs = TRUE); pr_set_docs()

OpenAPI 3 and a choice of interface

plumber 1.0.0

#* @get /path with query arguments assumed to be numbers

explicit as.integer() or a typed segment such as <n:int>

query values are strings

always

CORS filter with Access-Control-Allow-Origin: * pasted from a blog

a named origin from the environment, and a preflight answer

* opens the API to every page

always

A Docker image built FROM trestletech/plumber

FROM rstudio/plumber

the image is now maintained under rstudio/plumber

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

References