
A template for creating reproducible Shiny applications
June 7, 2025
create_template() to make an app



{rhino} by Appsilon: dashboard-focused{golem} by ThinkR: good but generic{teal} by Insight Engineering / Roche: pharma-focused{shinymeta} can also be used to create reproducible apps
install.packages("shinyscholar") installs all you need to create new appsinstall.packages("shinyscholar", dependencies = TRUE) to runcommoncreate_template() requires a dataframe of modules| component | long_component | module | long_module | map | result | rmd | save | download | async |
|---|---|---|---|---|---|---|---|---|---|
| select | Select data | async | Upload your own data | TRUE | FALSE | TRUE | TRUE | FALSE | TRUE |
| select | Select data | query | Query a database to obtain data | FALSE | FALSE | TRUE | TRUE | FALSE | FALSE |
| transform | Transform data | filter | Filter the data | TRUE | TRUE | TRUE | TRUE | TRUE | FALSE |
| plot | Plot data | hist | Plot the data as a histogram | FALSE | TRUE | TRUE | TRUE | TRUE | FALSE |
| plot | Plot data | scatter | Plot the data as a scatterplot | FALSE | TRUE | TRUE | TRUE | TRUE | FALSE |
common is an object passed between modulesNULL by defaultcommon$reset() allows it to be resetcommon in Disagappshaperesponse_nameaggagg_prepagg_prep_lorescovscovs_prepcovs_prep_lorescovs_matrixcovs_summarymeshprepfitfit_plotpredtransfermap_layerspolycreate_template() creates an empty app 🚀common_objects = c("raster", "histogram", "scatter")
shinyscholar::create_template(
path = file.path("~", "Documents"), name = "SSdemo", author = "Simon Smart",
include_map = TRUE, include_table = TRUE, include_code = TRUE,
common_objects = common_objects, modules = modules, install = TRUE)
SSdemo::run_SSdemo()├── DESCRIPTION Define dependencies
├── inst
│ └── shiny
│ ├── common.R Data objects shared between modules
│ ├── global.R Loads package and modules
│ ├── ui_helpers.R Functions to create module UI
│ ├── server.R App server
│ ├── ui.R App UI
│ ├── modules
│ │ ├── core_code.R Displays code
│ │ ├── core_intro.R Produces introductory walkthrough
│ │ ├── core_load.R Loads app
│ │ ├── core_mapping.R Creates map
│ │ ├── core_save.R Saves app
│ │ ├── select_async.md Module guidance
│ │ ├── select_async.R Module UI and server
│ │ ├── select_async.Rmd Reproduces the module
│ │ ├── select_async.yml Module configuration
│ │ ├── ... (repeated for other modules)
│ ├── Rmd
│ │ ├── gtext_load.Rmd Guidance text for each component
│ │ ├── gtext_plot.Rmd
│ │ ├── gtext_rep.Rmd
│ │ ├── references.Rmd Template for rep_refPackages
│ │ ├── text_about.Rmd Main panel on intro tab
│ │ ├── text_how_to_use.Rmd Detailed instructions
│ │ ├── text_intro_tab.Rmd Sidebar on intro tab
│ │ ├── text_loadsesh.Rmd Guidance for loading
│ │ ├── text_team.Rmd Lists developers
│ │ ├── userReport_intro.Rmd Introduction to markdown
│ │ └── userReport_module.Rmd
│ └── www
│ ├── css
│ │ └── styles.css
│ ├── favicon.ico
│ ├── js
│ │ └── shinyjs-funcs.js
│ └── logo.png
├── R
│ ├── helper_functions.R Various utility functions
│ ├── run_demo.R Function to run app
│ ├── select_async_f.R Function for each module
│ ├── select_query_f.R
│ ├── plot_histogram_f.R
│ └── plot_scatter_f.R
└── tests
└── testthat
├── test-load_user.R Tests for each module
├── test-load_database.R
├── test-plot_histogram.R
└── test-plot_scatter.R
input and output IDs are namespacedload, ns() appends load- to the ID so load-plot and load-number are createdR/:
<component>_<module>_f.Rinst/shiny/modules/:
<component>_<module>.R<component>_<module>.Rmd<component>_<module>.md<component>_<module>.ymltests/:
test-<component>_<module>.Rcommon$logger |> writeLog()type = "info" / "warning" / "error" a shinyalert::shinyalert() modal is also displayedtype = "starting" / "complete" keep track of slow taskslogger = NULL inside a function, i.e. when used in the rmarkdown messages are passed to message(), warning() or stop() instead{gargoyle} is used instead to explicitly trigger reactivityinit(<id>) is called during app start uptrigger(<id>) is called when a module runs successfullywatch(<id>) is used inside any outputs to be generated / other code that needs to runflowchart TD A[Input in UI] --> |input$| B([Computation in server]) B --> |output$| C(Output in UI) class A sin class B sser class C sout
flowchart TD A[Input in UI] --> |input$| C([Functions]) B[Existing data] --> |common$| C([Functions]) C --> |common$| D([Store in common]) D --> |output$| E(Output in Results) class A sin class B sin class C sser class D sser class E sout
common objects or input valuesselect_query() function in the example app is most complex 🚀select_query <- function(poly, date, token, logger = NULL) {
check_suggests()
if (!("matrix" %in% class(poly))){
logger |> writeLog(type = "error","poly must be a matrix")
return()
}
if (!is.character(date) || is.na(as.Date(date, format = "%Y-%m-%d"))) {
logger |> writeLog(type = "error","date must be a string with the format YYYY-MM-DD")
return()
}
if (nchar(token) < 200 || is.null(token)){
logger |> writeLog(type = "error", "This function requires a NASA token - see the documentation")
return()
}
# convert to terra object to calculate area and extent
terra_poly <- terra::vect(poly, crs = "EPSG:4326", type = "polygons")
area <- terra::expanse(terra_poly, unit = "km")
if (area > 1000000) {
logger |> writeLog(type = "error", paste0("Your selected area is too large (",round(area,0)," km2)",
" when the maximum is 1m km2. Please select a smaller area"))
return()
}
bbox <- c(min(poly[,1]), max(poly[,2]), max(poly[,1]), min(poly[,2]))
search_url <- glue::glue("https://ladsweb.modaps.eosdis.nasa.gov/api/v2/content/archives?products=MCD15A2H&temporalRanges={date}®ions=[BBOX]W{bbox[1]}%20N{bbox[2]}%20E{bbox[3]}%20S{bbox[4]}&archiveSets=61")
check <- check_url(search_url)
if (!is.null(check)){
image_req <- httr2::request(search_url ) |>
httr2::req_auth_bearer_token(token) |>
httr2::req_perform()
image_resp <- image_req |> httr2::resp_body_html()
image_links <- xml2::xml_find_all(image_resp, "//a")
image_urls <- xml2::xml_attr(image_links, "href")
} else {
logger |> writeLog(type = "error", "The FAPAR API is currently offline")
return()
}
# download and stitch together tiles
raster <- NULL
for (file in image_urls){
if (tools::file_ext(file) == "hdf"){
req <- httr2::request(file) |>
httr2::req_auth_bearer_token(token) |>
httr2::req_perform()
temp <- tempfile(fileext = ".hdf")
writeBin(httr2::resp_body_raw(req), temp)
tile <- terra::rast(temp)$Fpar_500m
if (is.null(raster)){
raster <- tile
} else {
raster <- terra::merge(raster, tile)
}
}
}
if (is.null(raster)){
logger |> writeLog(type = "error", paste0("No data was found for your selected area. ",
"This could be due to cloud coverage or because the area is not over land."))
return()
}
# reproject and crop
raster <- terra::project(raster, "EPSG:4326")
raster <- terra::crop(raster, terra_poly)
# count missing values and log accordingly
missing_values <- length(terra::values(raster)[terra::values(raster) > 1])
urban <- length(terra::values(raster)[terra::values(raster) == 2.5])
water <- length(terra::values(raster)[terra::values(raster) == 2.54])
if (missing_values == terra::ncell(raster)) {
logger |> writeLog(type = "error", paste0("No data was found for your selected area. ",
"This could be due to cloud coverage or because the area is not over land."))
return()
}
if (missing_values > 0) {
message <- glue::glue("{missing_values} pixels were removed.")
if (urban > 0) {
message <- paste(message, glue::glue("{urban} pixels were removed due to urban land use."), sep = " ")
}
if (water > 0) {
message <- paste(message, glue::glue("{water} pixels were removed due to water coverage."), sep = " ")
}
logger |> writeLog(message)
}
# remove missing values and rescale data to 0 - 100 %
raster <- terra::clamp(raster, upper = 1, value = FALSE) * 100
raster
}<identifier>_module_ui() - user inputs
<identifier>_module_server() - server function
Optionally:
<identifier>_module_result() - display outputs
<identifier>_module_map() - modify the map
<identifier>_module_rmd() - transfer data to rmarkdown
ns()tagList
observeEvent() triggered by input$runcommon$metacommontrigger() to run anything that relies on the resultsshow_map() or show_results() to change the viewobserveEvent() triggered by input$run observeEvent(input$run, {
# WARNING ####
if (is.null(common$raster)) {
common$logger |> writeLog(type = "error", "Please load a raster file")
return()
}
# FUNCTION CALL ####
raster_name <- c(common$meta$select_query$name, common$meta$select_async$name, common$meta$select_user$name)
histogram <- plot_hist(common$raster, as.numeric(input$bins), input$pal, raster_name, common$logger)
# LOAD INTO COMMON ####
common$histogram <- histogram
# METADATA ####
common$meta$plot_hist$bins <- as.numeric(input$bins)
common$meta$plot_hist$pal <- input$pal
common$meta$plot_hist$name <- raster_name
# TRIGGER ####
trigger("plot_hist")
show_results(parent_session)
shinyjs::show("download")
})common is saved to an .Rdscommon$statecommon$meta which are only for modules that have been runcommoncommon$statenames(common$meta)common in outside shiny and use the exact data used in the appload_file_path to point to a save file and it will be loaded automaticallysave_and_load() takes care of tedious coding {.mediumcode} 🚀{shiny} inputs are supportedTRUE.Rmd filecommon object straight into the markdown.zip of data and load that in the markdown (example below)metadata() takes care of some of the tedium 🚀save_and_load() this requires manual work afterwards and can therefore only be called once for each modulemetadata(".") # for all modules
metadata(".", module = "component_module") # for a single module
numericInput(ns("number"), "Enter a number", value = 5)
common$meta$component_module$number <- input$number # store
component_module_number <- common$meta$component_module$number # transfer to Rmd
{{component_module_number}} # use in Rmdtest_that("{shinytest2} recording: e2e_select_user", {
app <- shinytest2::AppDriver$new(app_dir = system.file("shiny", package = "demo"), name = "e2e_select_user")
app$set_inputs(tabs = "select")
app$set_inputs(selectSel = "select_user")
app$click("select_user-run")
common <- app$get_value(export = "common")
expect_true(is.null(common$scatter))
})ExtendedTask was added in Shiny v1.8.1 and enables tasks to run in the backgroundasync = TRUE for a module a different skeleton is usedasyncLog() is similar to writeLog() but can be used inside async functionsasync = FALSE as the last parameter instead of logger = NULLreturn(async %>% asyncLog("message)) sends errors back

