Skip to Content
Course content

137: Building Multi-Page Shiny Dashboards with bslib

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We’ve all been there: you build a Shiny app that starts as a simple one-page prototype, and it's a hit. Suddenly, your stakeholders want "just a few more things." You add a page for raw data, a page for a detailed map, and a page for settings. Before you know it, your ui.R is a wall of nested tabPanel calls and your server.R is a 1,500-line monolith where you're terrified to rename a single outputId because you might break a reactive chain three hundred lines away.

The Single-File Monolith

The naive way to build a multi-page app is to treat the entire dashboard as one giant object. You likely use navbarPage() or tabsetPanel() and just keep appending tabPanel() arguments. In the server, you write every output for every page in one long sequence. It looks something like this:

ui <- navbarPage(
  "Company Health",
  tabPanel("Revenue", plotOutput("rev_plot"), tableOutput("rev_table")),
  tabPanel("Churn", plotOutput("churn_plot"), tableOutput("churn_table")),
  tabPanel("Feedback", plotOutput("sent_plot"))
)

server <- function(input, output, session) {
  # Revenue logic
  output$rev_plot <- renderPlot({ ... })
  output$rev_table <- renderTable({ ... })
  
  # Churn logic
  output$churn_plot <- renderPlot({ ... })
  output$churn_table <- renderTable({ ... })
  
  # Feedback logic
  output$sent_plot <- renderPlot({ ... })
}

This works fine for a "Hello World" app, but it scales terribly. The biggest issue isn't the UI—it's the namespace. In the example above, every outputId must be unique across the entire application. If you decide to add a second "Table" to the Revenue page, you're inventing names like rev_table_2. Worse, your server function becomes a dumping ground for reactive values that are only relevant to one specific page but are globally accessible to the whole session. It's a recipe for "spaghetti code" where a change to a filter on page one accidentally triggers a heavy calculation on page three.

Scaling with page_navbar and Modules

If you want to build something that won't make you cringe six months from now, you should pair bslib::page_navbar() with Shiny modules. bslib gives us a modern Bootstrap 5 foundation that's far more flexible than the legacy navbarPage, but the real magic happens when you stop putting your page logic in the main server function.

Instead of cramming everything into one file, I treat each page as its own mini-app. I create a UI function and a Server function for each page. This isolates the namespaces. When I'm working on the "Churn" page, I don't have to care about what's happening on the "Revenue" page.

# The Churn Module
churnUI <- function(id) {
  ns <- NS(id)
  tagList(
    plotOutput(ns("churn_plot")),
    tableOutput(ns("churn_table"))
  )
}

churnServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    output$churn_plot <- renderPlot({ ... })
    output$churn_table <- renderTable({ ... })
  })
}

# The Main App
ui <- page_navbar(
  title = "Company Health",
  nav_panel("Revenue", revUI("rev_page")),
  nav_panel("Churn", churnUI("churn_page")),
  nav_panel("Feedback", feedUI("feed_page"))
)

server <- function(input, output, session) {
  revServer("rev_page")
  churnServer("churn_page")
  feedServer("feed_page")
}

The Boilerplate Tax vs. The Maintenance Debt

Now, I'll be honest: this approach feels like more work upfront. You have to wrap your IDs in ns(), and you have to write separate functions for every page. You're paying a "boilerplate tax."

But you're doing it to avoid "maintenance debt." When you use page_navbar and modules, you can move a page's logic into a separate R file entirely. If a bug appears in the Churn calculations, you know exactly which file to open. You can also reuse modules; if you suddenly need two "Revenue" views for different regions, you just call revUI("region_a") and revUI("region_b"). The modular approach transforms your app from a fragile house of cards into a set of LEGO bricks.

One final tip: use bslib's nav_spacer() within your page_navbar. It allows you to push certain links (like "Settings" or "Help") to the right side of the navigation bar, giving your app a professional, polished feel that the old navbarPage simply couldn't achieve without custom CSS hacks.




📋 Practical Task

Build a Regional Sales Performance Tracker

Your goal is to move away from a monolithic Shiny structure and build a modularized multi-page dashboard using bslib.

Requirements:

  • Use bslib::page_navbar() as the main layout.
  • Create two distinct modules: regional_sales_ui/server and product_breakdown_ui/server.
  • The Regional Sales page should contain a plotOutput showing a mock trend line and a selectInput to filter by region.
  • The Product Breakdown page should contain a tableOutput showing mock product performance and a sliderInput to filter by minimum revenue.
  • Ensure that all outputId and inputId references within the modules are correctly wrapped in ns().
  • In the main page_navbar, include a third nav_panel for "About" (this can be simple text) and use nav_spacer() to separate the main data panels from the "About" panel.

Success Criteria: The app should launch without namespace errors, and changing the filter on the Regional Sales page should not trigger a re-render of the Product Breakdown table.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.