{
"meta" : {
"name" : "Shiny golem config",
"version" : "0.0.1",
"description" : "An example of an app configured by a config file in the cloud",
"author" : "Discindo",
"email" : "hello@discindodata.com",
"logo" : "www/logo.png",
"favicon" : "favicon.ico",
"theme" : "default",
"language" : "en",
"homepage" : "discindo.org"
},
"general" : {
"title" : "Test Title",
"subtitle" : "A little longer subtitle just to show it (disable with `null`)",
"data_path" : "https://raw.githubusercontent.com/discindo/shiny-golem-json-config/refs/heads/main/iris.csv",
"data_format" : "csv"
},
"pages" : {
"home" : {
"title" : "Home",
"icon" : "home",
"url" : "/",
"template" : "inst/html/home.html",
"ui" : null,
"server" : null
},
"data" : {
"title" : "Data",
"icon" : "table",
"url" : "/data",
"template" : null,
"ui" : "mod_data_ui",
"server" : "mod_data_server"
},
"about" : {
"title" : "About",
"icon" : "info",
"url" : "/about",
"template" : "inst/html/about.html",
"ui" : null,
"server" : null
}
}
}
A common question when developing a Shiny application goes along the lines of “How can I configure this?”, “Can it show different data?”, “What if I have a new client with a new logo and color scheme?”. There are of course many ways to approach configuration questions like these. One way is to implement server logic tied to some data about the client that logged in, keeping all the configuration logic in one codebase and have one deployed instance. Another way is to simply copy the code, make modifications to accommodate the differences in the UI and required data and deploy twice. Either approach, and the myriad in-between solutions, have their merits and drawbacks (complexity of code, maintainability, scalability, budget, etc.). Below I write about an approach that is somewhere in the middle that I used recently to provide flexibility for a client without major refactoring of the code, while maintaining a single code repository. Keeping it relatively simple, in other words.
In this example, we assume that there are predefined units of code (modules) that process different datasets and display results. The code can accommodate several different dataset types, but not all users have or need to access all types of data. Or in a subscription setting, one could say not all users have the subscription for each unit.
Furthermore, lets assume that there is some substantive data processing that occurrs before the data arrives to our Shiny application. For example, an analyst, you are performing some extensive machine learning procedure for different clients, resulting in charts and tables that need to be displayed in the Shiny application. The end-user of the application, is not analyzing the data, but seeing the final results, perhaps on some schedule.
In this scenario, it would help if the analyst can just update a cloud storage bucket with some results and automatically update the content of the Shiny application. The Shiny code is of course pointed to the cloud storage bucket (or database), so on the next session start can display the new data. Then, each client would have a separate bucket, and a separate instance of the Shiny application, to allow for flexibility between clients and different datasets versions for the same client.
Config JSON
Nothing particularly novel here. We just create a JSON file with a simple structure. The “meta” section has general info about the application and the client (logo, homepage, etc.). The “general” section contains info on the title of the particular app instance, and path to the data object. Depending on the needs this can be a folder (bucket) with several files, or a single file (like in the simple example here). Finally, we have the “pages” section where add information specific to the different pages/tabs the application has.
Some of the configurability comes in here. We can have separate pages for different types of data or subscription levels and enable/disable pages from this section accordingly.
The config can be placed in the inst
folder of the R package (if {golem}
) or some other folder that is in the resource path of the Shiny app. But it can also live in the remote cloud location. Of course the Shiny app should have the required permissions to access these files.
In the example here, to keep things simple, the ‘remote-data’ folder is a public folder in the GitHub repository for the code used in this post. No additional files or variables are needed to access the config JSON and the data files. But if the data are in a private bucket at a cloud provider, the app would need to have read permissions for that bucket. Either through AWS IAM credentials, or Google cloud token, etc..
Shiny code
The Shiny app will need to load the config JSON from the cloud or local location, and use the configurations set by the user to generate the right content. In the example, I am using the {golem}
setup, so there are separate app_ui.r
and app_server.r
scripts. Each one loads the JSON config using jsonlite
and accesses the slots in the resulting list for the values it needs. If there are some expensive or repeated steps performed in the ui and server functions, we could use golem options or some other mechanism to parse the config JSON only once and pass the list as an argument or option to the main ui and server functions. But it this, admittedly simplified example, we parse the config twice, as its quite small.
In the {rhino}
framework, something similar can be achieved by loading the config in app.R
, and then using it in functions from /logic/
. And in a “base” Shiny app, a straightforward approach would be to parse the config in global.r
which would make the variable available in the scope of the ui and server functions.
UI
In the ui function we:
- load the config,
- generate the pages UIs using
nav_panel
frombslib
, - generate some basic aspects of the UI (brand, title, subtitle),
- and finally
splice
in the pages/tabs
To generate the content of the pages, we use either HTML Templates or Shiny modules. Of course this is not necessary, we can use R to write the HTML for the Home and About pages, or can make modules for all the pages, even if those modules are not intended to be reused. In the {golem}
approach it is convenient that the module’s functions are exported from the package and can be accessed with getFunction
. Similar approaches would work for {rhino}
and base shiny setups.
<- function(request) {
app_ui <- jsonlite::read_json("https://raw.githubusercontent.com/discindo/shiny-golem-json-config/refs/heads/main/remote-data/app-config.json")
config <- purrr::map(
pages $pages, function(x) {
configif (!is.null(x$template)) {
return(bslib::nav_panel(
title = x$title,
::htmlTemplate(x$template)
shiny
))else if (!is.null(x$ui)) {
} return(bslib::nav_panel(
title = x$title,
getFunction(x$ui)(id = "data_1")
))else {
} return(bslib::nav_panel(
title = x$title,
$title
x
))
}
}
)::tagList(
shiny# Leave this function for adding external resources
golem_add_external_resources(),
# Your application UI logic
::div(
shinyclass = "container",
::page_navbar(
bslibtheme = bslib::bs_theme(
bootswatch = "litera",
base_font = bslib::font_google("Lato")
),inverse = FALSE,
underline = FALSE,
title = shiny::a(
href = config$meta$homepage,
target = "_blank",
::img(src = config$meta$logo, height = "70px")
shiny
),header = shiny::div(
class = "p-5 text-center",
::h4(config$general$title),
shiny::h6(config$general$subtitle)
shiny
),::nav_spacer(),
bslib::splice(unname(pages))
rlang
)
)
) }
Server
In this example, the server is even simpler. All we need to do here is cycle over the $pages
slot of the config, find the pages that have a server
slot, and call the module server (obviously using the same id
as in the ui). The data is passed to the module server as an argument, although this is not strictly required, as we can use session$userData
to share the object across modules, or use some other mechanism. Again, in this simple example, there is only one data object, and we don’t do much with it apart to render a table. But in a real application we might need multiple files and more involved server logic. If many server data objects are needed, it might make sense to pass the paths to these objects to the module’s server rather than the data it self.
<- function(input, output, session) {
app_server # Your application server logic
<- jsonlite::read_json("inst/app-config.json")
config <- read.csv(config$general$data_path)
data ::map(config$pages, function(x) {
purrrif (!is.null(x$server)) {
getFunction(x$server)(id = "data_1", data = data)
}
}) }
In the event that we need multiple modules per page, for example modules for table, chart 1, chart2, we could organize the code in one “page-level” module, that calls individual tables, charts, maps modules. This way, we don’t have to build functionality to specify and call multiple modules per page from the config file. i.e., we could keep the config and top-level code simple, and build complexity at a lower level.
Configurability
The configurability in this simple example comes from the $general$data_path
entry in the config. We can toggle between iris
and mtcars
. What other ways can this config json be used to customize the shiny app:
- have multiple ‘data’ pages, with different datasets or outputs
- use different data processing modules
- use different homepage link, logo, title, subtitle, …
- use different input data formats specified through the
$general$data_format
(this is not implemented in the example, but one could writeswitch
logic to read csv, excel, parquet, qs, RDS, etc.) - the config can be modified to have
data_path
anddata_format
entries on a per page (module) level. For example to load csv data in one page and a map in another.
Using this approach is very similar in to using {golem}
’s golem-config.yml
, with the main difference that the the location of the config JSON can be anywhere. Like the golem-config.yml
we can use the above approach to deploy a staging vs production app instances (i.e. by pointing them to staging vs production data buckets).
The data bucket directory structure:
remote-data/
├── app-config.json
├── iris.csv
└── mtcars.csv
Of course, each data bucket, with its own config and data objects should in this case correspond to a deployed instance of the application. In the event of multiple clients, this makes sense, but it would obviously backfire after 5-10 clients. Maintaining that many data buckets and instances would be best approached with a database.
Summary
In this post, we explored how to configure a Shiny application using a remote JSON file. This approach allows for flexible and dynamic updates to the app’s content and appearance without major code refactoring. We discussed the structure of the JSON configuration file, how to load it in the Shiny app, and how to use it to generate the UI and server logic dynamically. This method can be particularly useful for applications that need to display different data or configurations for different clients, enabling easy updates by simply modifying the JSON file in a cloud storage location.