rguides

Build a Sales Dashboard with Shiny

A sales dashboard transforms raw sales data into actionable insights. Instead of static spreadsheets, you get interactive views where stakeholders can filter by date range, product category, or region—all without touching code. In this guide, you’ll build a complete sales dashboard from scratch using Shiny and ggplot2.

What you will build

By the end of this guide, you’ll have a dashboard with:

  • Key performance indicators (KPIs) at the top
  • Interactive date range and category filters
  • Time series chart showing sales over time
  • Bar chart comparing product categories
  • Data table with detailed transactions

The dashboard will look professional and respond instantly to user input.

Setting up your project

First, create a new Shiny project in RStudio:

# Install required packages if needed
install.packages(c("shiny", "ggplot2", "dplyr", "lubridate"))

# Load libraries
library(shiny)
library(ggplot2)
library(dplyr)
library(lubridate)

For this example, we’ll generate sample sales data. In production, you’d connect to a database or read from a CSV file.

Generating sample sales data

Create a function that generates realistic sales data:

generate_sales_data <- function(n = 1000) {
  set.seed(123)
  
  categories <- c("Electronics", "Clothing", "Food & Beverage", "Home & Garden")
  regions <- c("North", "South", "East", "West")
  
  data.frame(
    date = sample(seq(as.Date("2024-01-01"), as.Date("2024-12-31"), by = "day"), n, replace = TRUE),
    category = sample(categories, n, replace = TRUE),
    region = sample(regions, n, replace = TRUE),
    product = paste0("Product ", sample(1:50, n, replace = TRUE)),
    quantity = sample(1:20, n, replace = TRUE),
    unit_price = runif(n, 10, 500) |> round(2)
  ) |>
    mutate(total_sales = quantity * unit_price)
}

sales_data <- generate_sales_data(2000)
head(sales_data)
#          date     category region   product quantity unit_price total_sales
# 1 2024-03-15 Electronics    West Product 32        7      285.43      1998.01
# 2 2024-08-22       Clothing    East Product 18       19       68.31      1297.89
# ...

This gives us realistic-looking data to visualize.

Building the dashboard UI

The UI defines what the user sees. We’ll use a sidebar layout with filters on the left and visualizations on the right:

ui <- fluidPage(
  theme = bslib::bs_theme(version = 4, bootswatch = "minty"),
  
  titlePanel("Sales Dashboard"),
  
  sidebarLayout(
    sidebarPanel(
      h4("Filters"),
      
      dateRangeInput(
        "date_range",
        "Select Date Range:",
        start = min(sales_data$date),
        end = max(sales_data$date),
        min = min(sales_data$date),
        max = max(sales_data$date)
      ),
      
      selectInput(
        "category",
        "Product Category:",
        choices = c("All", unique(sales_data$category)),
        selected = "All"
      ),
      
      selectInput(
        "region",
        "Region:",
        choices = c("All", unique(sales_data$region)),
        selected = "All"
      ),
      
      hr(),
      helpText("Adjust filters to update all visualizations.")
    ),
    
    mainPanel(
      fluidRow(
        valueBoxOutput("total_sales_box", width = 4),
        valueBoxOutput("total_orders_box", width = 4),
        valueBoxOutput("avg_order_box", width = 4)
      ),
      
      fluidRow(
        plotOutput("sales_trend", height = "300px")
      ),
      
      fluidRow(
        plotOutput("category_breakdown", height = "300px")
      ),
      
      fluidRow(
        dataTableOutput("sales_table")
      )
    )
  )
)

The UI uses valueBoxOutput() for KPIs, plotOutput() for charts, and dataTableOutput() for the detailed table. Each output corresponds to a render function in the server.

Creating the server logic

The server handles all the reactive logic. When filters change, all outputs update automatically:

server <- function(input, output, session) {
  
  # Reactive filtered data
  filtered_data <- reactive({
    data <- sales_data
    
    # Filter by date range
    data <- data |>
      filter(date >= input$date_range[1] & date <= input$date_range[2])
    
    # Filter by category (if not "All")
    if (input$category != "All") {
      data <- data |> filter(category == input$category)
    }
    
    # Filter by region (if not "All")
    if (input$region != "All") {
      data <- data |> filter(region == input$region)
    }
    
    data
  })
  
  # KPI: Total Sales
  output$total_sales_box <- renderValueBox({
    total <- filtered_data() |>
      summarise(total = sum(total_sales)) |>
      pull(total)
    
    valueBox(
      value = scales::dollar(total, big.mark = ","),
      subtitle = "Total Sales",
      icon = icon("dollar-sign"),
      color = "green"
    )
  })
  
  # KPI: Total Orders
  output$total_orders_box <- renderValueBox({
    orders <- nrow(filtered_data())
    
    valueBox(
      value = scales::number(orders, big.mark = ","),
      subtitle = "Total Orders",
      icon = icon("shopping-cart"),
      color = "blue"
    )
  })
  
  # KPI: Average Order Value
  output$avg_order_box <- renderValueBox({
    avg <- filtered_data() |>
      summarise(avg = mean(total_sales)) |>
      pull(avg)
    
    valueBox(
      value = scales::dollar(avg, big.mark = ","),
      subtitle = "Avg Order Value",
      icon = icon("chart-line"),
      color = "purple"
    )
  })
  
  # Sales Trend Chart
  output$sales_trend <- renderPlot({
    filtered_data() |>
      group_by(date) |>
      summarise(daily_sales = sum(total_sales)) |>
      ggplot(aes(x = date, y = daily_sales)) +
      geom_line(color = "steelblue", linewidth = 1) +
      geom_smooth(method = "loess", se = FALSE, color = "darkred") +
      labs(
        title = "Daily Sales Over Time",
        x = "Date",
        y = "Sales ($)"
      ) +
      theme_minimal() +
      scale_y_continuous(labels = scales::dollar_format()) +
      theme(
        plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
        axis.text.x = element_text(angle = 45, hjust = 1)
      )
  })
  
  # Category Breakdown Chart
  output$category_breakdown <- renderPlot({
    filtered_data() |>
      group_by(category) |>
      summarise(category_sales = sum(total_sales)) |>
      ggplot(aes(x = reorder(category, -category_sales), y = category_sales, fill = category)) +
      geom_bar(stat = "identity") +
      labs(
        title = "Sales by Category",
        x = "Category",
        y = "Sales ($)"
      ) +
      theme_minimal() +
      scale_fill_brewer(palette = "Set2") +
      scale_y_continuous(labels = scales::dollar_format()) +
      theme(
        plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
        legend.position = "none",
        axis.text.x = element_text(angle = 45, hjust = 1)
      )
  })
  
  # Sales Data Table
  output$sales_table <- renderDataTable({
    filtered_data() |>
      head(100) |>
      mutate(
        date = format(date, "%Y-%m-%d"),
        total_sales = scales::dollar(total_sales, big.mark = ",")
      ) |>
      DT::datatable(
        options = list(
          pageLength = 10,
          dom = "tip",
          columnDefs = list(
            list(className = "dt-right", targets = 3:5)
          )
        )
      )
  })
}

The key to this code is filtered_data(). Whenever a user changes any filter, Shiny automatically recalculates the filtered dataset and updates every output that depends on it.

Running the dashboard

Now put it all together and run the app:

# Load DT for data tables
library(DT)
library(bslib)
library(scales)

shinyApp(ui, server)

When you run this, you’ll see a fully functional dashboard. Try changing the date range or selecting different categories—watch how all the charts and KPIs update instantly.

Adding polish

A few enhancements make the dashboard more professional:

Add a theme

Use bslib for better styling:

ui <- fluidPage(
  theme = bs_theme(version = 4, bootswatch = "minty"),
  # ... rest of UI
)

Add conditional filtering

Some filters only make sense when others are selected:

observe({
  available_regions <- filtered_data() |>
    distinct(region) |>
    pull(region)
  
  updateSelectInput(
    session,
    "region",
    choices = c("All", available_regions),
    selected = input$region
  )
})

This updates the region dropdown to show only regions that have data for the selected date range and category.

Add download button

Let users export the filtered data:

# In UI
downloadButton("download_data", "Download Data")

# In Server
output$download_data <- downloadHandler(
  filename = function() {
    paste("sales_data_", Sys.Date(), ".csv", sep = "")
  },
  content = function(file) {
    write.csv(filtered_data(), file, row.names = FALSE)
  }
)

Complete application structure

For larger dashboards, organize your code into separate files:

sales-dashboard/
├── app.R              # Main app that sources other files
├── global.R           # Data loading and helper functions
├── ui.R               # UI definition
├── server.R           # Server logic
├── www/
│   └── custom.css     # Custom styling
└── data/
    └── sales_data.rds # Preprocessed data

In app.R:

# Load all components
source("global.R")
source("ui.R")
source("server.R")

shinyApp(ui, server)

This separation makes it easier to maintain and test each component.

Deployment

When you’re ready to share your dashboard, you have several options:

  • shinyapps.io: Free hosting by Posit (formerly RStudio)
  • Posit Connect: Professional hosting with authentication
  • Shiny Server: Self-hosted on your own server
  • Docker: Containerize for maximum portability

Deploying to shinyapps.io is straightforward:

library(rsconnect)
rsconnect::setAccountInfo(name = "your-account", token = "your-token", secret = "your-secret")
rsconnect::deployApp("sales-dashboard/")

Your dashboard will be live at your-account.shinyapps.io/sales-dashboard.

Data architecture

A production sales dashboard separates data access from the Shiny application. Fetch data in a reactive() that caches for a configured interval using reactiveFileReader() (for file-based data) or a reactivePoll() (for database queries). This prevents the dashboard from hammering the database on every user interaction. For multi-user dashboards, cache at the session level for user-specific data and at the application level for shared reference data.

Filtering state

Filters (date range, region, product) should drive all charts and KPIs consistently. Centralize filter state in reactiveValues() shared across modules rather than duplicating filter logic in each chart. Use debounce() on text input filters to delay reactive updates until the user stops typing, preventing flickering charts during input. Export the current filter state to the URL with shiny::updateQueryString() so dashboards can be bookmarked and shared with pre-applied filters.

Dashboard architecture

A sales dashboard in Shiny typically has three layers: data access (connecting to the database or reading files), business logic (computing KPIs and preparing data for display), and presentation (layout, charts, tables). Separating these layers makes the app easier to maintain.

Load reference data once at startup (outside the server function), product catalog, region hierarchy, fiscal calendar. Dynamic data (actual sales figures) loads inside reactive expressions that respond to filter inputs. This separation ensures static lookups do not run on every interaction.

For large sales datasets, push aggregations to the database using dbplyr. tbl(con, "sales") %>% filter(date >= input$start_date) %>% group_by(region, product) %>% summarise(total = sum(revenue)) %>% collect() executes in the database and returns only the summary. This scales to millions of records without loading all data into R.

KPI cards

The standard sales dashboard structure has KPI cards at the top (total revenue, units sold, margin, growth) and detail charts below. bslib::value_box() renders KPI cards with a title, value, icon, and optional trend indicator.

Compute each KPI as a reactive expression: total_revenue <- reactive({ filtered_sales() %>% summarise(sum(revenue)) %>% pull() }). Reference it in multiple output renders: the KPI card, the title of a chart, and a summary table can all use total_revenue() without recomputing it.

Period-over-period comparison: compute the current period value and the prior period value, then display the percentage change. scales::percent(change, accuracy = 0.1, style_positive = "plus") formats with a plus sign for positive change.

Filter controls

A sidebar with connected filters: date range picker, multi-select for region, multi-select for product category. dateRangeInput("dates", "Date Range", start = floor_date(today(), "year"), end = today()) defaults to year-to-date. selectizeInput("regions", "Region", choices = unique(df$region), multiple = TRUE, selected = NULL) for multi-select with search.

observeEvent(input$reset, { updateDateRangeInput(session, "dates", start = floor_date(today(), "year"), end = today()); updateSelectizeInput(session, "regions", selected = character(0)) }) resets all filters with a button. A reset button reduces frustration when users apply too many filters and cannot see any data.

Cascading filters: selecting a region should update the product choices to only those products sold in that region. observeEvent(input$regions, { products_in_region <- df %>% filter(region %in% input$regions) %>% distinct(product) %>% pull(); updateSelectizeInput(session, "products", choices = products_in_region) }).

Chart types for sales data

Time series: plotly::plot_ly(df, x = ~date, y = ~revenue, type = "scatter", mode = "lines") for trend. Vertical reference lines for targets, fiscal periods, and notable events with add_segments().

Bar charts: revenue by region or product. fct_reorder() to sort bars by value. geom_col(aes(fill = above_target), data = sales_by_region) with conditional coloring (red/green based on target attainment).

Waterfall charts for YTD budget variance: show starting balance, each contributor (regions or months), and the final total. ggwaterfall or manually constructed bar charts with geom_col() plus text annotations.

Scatter with bubble size for three-variable analysis: revenue (x), margin (y), and units (bubble size). plotly::plot_ly(type = "scatter", mode = "markers", marker = list(size = ~sqrt(units) * 5)).

Performance and caching

Cache expensive aggregations with shiny::bindCache(). output$revenue_chart <- renderPlot({ ... }) %>% bindCache(input$start_date, input$end_date, input$regions) caches the chart output keyed to the filter inputs. The first request with a specific filter combination computes the chart; subsequent requests return the cached version.

For dashboards with many concurrent users, memoise::memoise(aggregate_fn, cache = memoise::cache_filesystem("cache/")) caches aggregations to disk, shared across server workers. This is more appropriate than Shiny’s session-local cache when multiple users apply the same filters.

Pre-aggregate data for the most common filter combinations during off-peak hours and store the summaries. Dashboard queries against pre-aggregated summaries are orders of magnitude faster than querying raw transaction data.

Summary

You built a complete sales dashboard with:

  • Three KPI value boxes showing total sales, orders, and average order value
  • Interactive date range, category, and region filters
  • Time series visualization showing daily sales trends
  • Bar chart breaking down sales by product category
  • Data table with detailed transaction records

The key concepts you learned—reactive filtering, modular output rendering, and separating UI from server—apply to any Shiny dashboard you build. These patterns scale to complex applications with dozens of inputs and outputs.

See also