Organising Shiny Apps with Modules

· 5 min read · Updated March 11, 2026 · intermediate
r shiny web ui programming

Shiny apps start small. A few inputs, a couple of outputs, and everything lives comfortably in a single app.R file. Then requirements grow. The app adds tabs, more interactivity, and eventually you’re scrolling through thousands of lines trying to find where that one reactive expression lives. This is the problem Shiny modules solve.

Modules let you break your app into self-contained pieces. Each module has its own UI and server logic. You can reuse modules across apps, test them in isolation, and keep your code organized as projects scale.

What Are Shiny Modules?

A module is a pair of functions: one that defines the UI and one that defines the server logic. Unlike regular functions, modules use namespaces to avoid conflicts. When you call a module function, it returns UI elements with unique IDs that won’t clash with other parts of your app.

The simplest module wraps a single input and output:

library(shiny)

# Module UI function
simplePlotUI <- function(id) {
  ns <- NS(id)
  tagList(
    selectInput(ns("var"), "Select variable", 
                choices = names(mtcars)),
    plotOutput(ns("plot"))
  )
}

# Module server function
simplePlotServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    output$plot <- renderPlot({
      hist(mtcars[[input$var]], 
           main = input$var,
           col = "steelblue",
           border = "white")
    })
  })
}

# App that uses the module
ui <- fluidPage(simplePlotUI("plot1"))
server <- function(input, output, session) {
  simplePlotServer("plot1")
}
shinyApp(ui, server)

The NS(id) function creates a namespace. When you call simplePlotUI("plot1"), the select input gets ID plot1-var and the plot output gets ID plot1-plot. The moduleServer() function handles the server-side namespace automatically.

Building a Multi-Tab App

Modules shine when you need repeated patterns. Consider an app with multiple analysis tabs, each showing the same type of visualization with different data. Instead of copying code, create one module and use it twice.

# Module for data exploration
explorerUI <- function(id, label) {
  ns <- NS(id)
  tagList(
    h4(label),
    fluidRow(
      column(6, selectInput(ns("x"), "X variable", names(mtcars))),
      column(6, selectInput(ns("y"), "Y variable", names(mtcars)))
    ),
    plotOutput(ns("scatter")),
    verbatimTextOutput(ns("summary"))
  )
}

explorerServer <- function(id, data) {
  moduleServer(id, function(input, output, session) {
    output$scatter <- renderPlot({
      req(input$x, input$y)
      plot(mtcars[[input$x]], mtcars[[input$y]],
           xlab = input$x, ylab = input$y,
           pch = 19, col = "darkgray")
    })
    
    output$summary <- renderPrint({
      req(input$x, input$y)
      summary(mtcars[, c(input$x, input$y)])
    })
  })
}

# App with two instances of the same module
ui <- fluidPage(
  tabsetPanel(
    tabPanel("Engine analysis", explorerUI("eng", "Engine Variables")),
    tabPanel("Size analysis", explorerUI("size", "Size Variables"))
  )
)

server <- function(input, output, session) {
  explorerServer("eng", mtcars)
  explorerServer("size", mtcars)
}

shinyApp(ui, server)

Both tabs do the same thing—plot variables and show a summary—but they’re completely independent. Change the module code once, and both tabs update.

Passing Data Into Modules

The example above passes mtcars directly. In real apps, you often need to pass reactive data or parameters. Modules handle this the same way as regular Shiny: use reactive expressions.

# Module that accepts reactive data
dataExplorerUI <- function(id) {
  ns <- NS(id)
  tagList(
    selectInput(ns("var"), "Variable", choices = NULL),
    verbatimTextOutput(ns("stats"))
  )
}

dataExplorerServer <- function(id, data) {
  moduleServer(id, function(input, output, session) {
    # Update choices when data changes
    observe({
      req(data())
      updateSelectInput(session, "var", 
                        choices = names(data()))
    })
    
    output$stats <- renderPrint({
      req(input$var, data())
      summary(data()[[input$var]])
    })
  })
}

# Usage in app
ui <- fluidPage(dataExplorerUI("explorer"))
server <- function(input, output, session) {
  # Reactive data source
  dataset <- reactive({
    # Could come from file, database, API
    mtcars
  })
  
  dataExplorerServer("explorer", dataset)
}

The data parameter is a function that returns data. Inside the module, you call data() to get the current value. This makes the module respond to changes in the source data.

Module Communication

Sometimes modules need to talk to each other. A common pattern: one module filters data, another displays it. You can connect modules using shared reactive values.

# Data filter module
filterModuleUI <- function(id) {
  ns <- NS(id)
  tagList(
    sliderInput(ns("mpg"), "Min MPG", 10, 35, 20),
    textOutput(ns("count"))
  )
}

filterModuleServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    filtered <- reactive({
      mtcars[mtcars$mpg >= input$mpg, ]
    })
    
    output$count <- renderText({
      nrow(filtered()) |> paste0(" cars")
    })
    
    # Return the reactive for other modules to use
    filtered
  })
}

# Display module
displayModuleUI <- function(id) {
  ns <- NS(id)
  plotOutput(ns("plot"))
}

displayModuleServer <- function(id, data) {
  moduleServer(id, function(input, output, session) {
    output$plot <- renderPlot({
      req(data())
      plot(data()$wt, data()$mpg,
           xlab = "Weight", ylab = "MPG",
           pch = 19, col = "steelblue")
    })
  })
}

# App wiring them together
ui <- fluidPage(
  filterModuleUI("filter"),
  displayModuleUI("display")
)

server <- function(input, output, session) {
  filtered_data <- filterModuleServer("filter")
  displayModuleServer("display", filtered_data)
}

The filter module returns a reactive expression. The display module accepts it as an argument. When the slider moves, the filter updates, and the plot automatically refreshes.

Organizing Module Files

As your app grows, keep modules in separate files. A common structure:

app/
├── app.R
├── modules/
│   ├── __init__.R
│   ├── dataInput.R
│   ├── plotViewer.R
│   └── tableDisplay.R
└── helpers.R

Each file contains one module pair. The __init__.R file loads all modules:

# modules/__init__.R
source("dataInput.R", local = TRUE)
source("plotViewer.R", local = TRUE)
source("tableDisplay.R", local = TRUE)

In app.R, source the directory:

source("modules/__init__.R", local = TRUE)

ui <- fluidPage(
  dataInputUI("input1"),
  plotViewerUI("plot1")
)

server <- function(input, output, session) {
  data <- dataInputServer("input1")
  plotViewerServer("plot1", data)
}

shinyApp(ui, server)

This structure scales well. You can have ten modules in a complex app and still navigate quickly to the file you need.

When to Use Modules

Not every app needs modules. A simple single-file app with five inputs and two outputs is easier to read as-is. Modules add overhead: extra function definitions, namespace management, and another layer of indirection.

Consider modules when your app meets any of these conditions:

  • Multiple similar sections that could share code
  • A team working on different features simultaneously
  • Tests you want to run on individual components
  • Reusable components you plan to use across apps

Start simple. If your app.R passes 500 lines, think about extracting a module. If you’re copying and pasting UI or server code, definitely extract a module.


Modules transform Shiny from a quick prototyping tool into something capable of production applications. The initial setup takes a little longer, but the maintainability gains are substantial. Your future self, scrolling through well-organized code, will thank you.

See Also