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.
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
- Getting Started with Shiny — Build your first Shiny app
- Organising Shiny Apps with Modules — Scale your dashboard with reusable components
- Building UI Components in Shiny — Advanced UI patterns for dashboards