---
title: Build a sales dashboard with the GraphQL Admin API
description: >-
  Build an embedded app that shows how a store is doing this week, with a
  headline sales metric, a trend chart, a top-products leaderboard, and empty
  and error states, powered by ShopifyQL and the GraphQL Admin API.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/shopifyql/graphql-admin-api/build-a-sales-dashboard?extension=react
  md: >-
    https://shopify.dev/docs/apps/build/shopifyql/graphql-admin-api/build-a-sales-dashboard.md?extension=react
---

# Build a sales dashboard with the Graph​QL Admin API

In this tutorial, you'll build an embedded app that reports a store's weekly sales. The app shows the total sales for the week with the change from last week, charts the daily trend, and ranks the top-selling products.

## What you'll learn

You'll learn how to do the following tasks:

* Write a [ShopifyQL](https://shopify.dev/docs/api/shopifyql) query for a store's daily sales this week and last.
* Read the total from the [`tableData`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery) response and compute the week-over-week change.
* Rank the store's top products with a second query that uses [`GROUP BY TOP`](https://shopify.dev/docs/api/shopifyql/latest/syntax/group-by).

![Screenshot of the finished sales dashboard embedded in the Shopify admin, showing a total sales metric with a week-over-week change badge, a daily trend line chart, a daily breakdown table, and a top products table.](https://shopify.dev/assets/assets/images/shopifyql/sales-dashboard/finished-dashboard-BZADFTHZ.png)

## Requirements

[Scaffold an app](https://shopify.dev/docs/apps/build/scaffold-app)

An embedded app built from the [React Router template](https://github.com/Shopify/shopify-app-template-react-router). An app built with Shopify CLI has [authentication](https://shopify.dev/docs/apps/build/authentication-authorization) already configured, so your app can make authenticated GraphQL Admin API requests.

[Request protected customer data access](https://shopify.dev/docs/apps/launch/protected-customer-data)

`shopifyqlQuery` requires [Level 2 access to protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data). Approval can take time, so request it early.

[Use a supported API version](https://shopify.dev/docs/api/admin-graphql)

`shopifyqlQuery` is available in the GraphQL Admin API as of version `2025-10`.

## Project

[View on GitHub](https://github.com/Shopify/example-shopifyql--sales-dashboard--react)

### Request the access scope

[`shopifyqlQuery`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery) requires the [`read_reports`](https://shopify.dev/docs/api/usage/access-scopes#authenticated-access-scopes) access scope. Request it in your app's configuration so that users grant it during installation.

#### Add the access scope

In `shopify.app.toml`, set the `scopes` value to `read_reports`. When you run your app, Shopify CLI prompts you to update the app's configuration.

***

`shopifyqlQuery` requires the `read_reports` access scope and [Level 2 access to protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data#requirements), which covers the name, address, phone, and email fields. Request both.

## /shopify.app.toml

```toml
# This file stores configuration for your Shopify app.
# `shopify app config link` populates client_id, application_url, and redirect_urls.
# Learn more: https://shopify.dev/docs/apps/tools/cli/configuration
client_id = ""


[access_scopes]
# read_reports grants access to a store's analytics and reporting data, which
# ShopifyQL queries read through the shopifyqlQuery field. Request it when the
# merchant installs your app.
scopes = "read_reports"


[webhooks]
api_version = "2025-10"


  # Handled by: /app/routes/webhooks.app.uninstalled.jsx
  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks/app/uninstalled"
```

### Write the Shopify​QL query

The dashboard shows this week's total sales, the change from last week, and the daily trend. ShopifyQL expresses all of that as a single query string.

#### Run the query in a loader

In the app's loader, authenticate the request, then call `admin.graphql` with a query that wraps your ShopifyQL string in the `shopifyqlQuery` field.

Request `tableData` for the results and `parseErrors` to detect an invalid query. Before reading the data, check the `errors` array so your app can handle a missing access scope or ungranted protected customer data access. The loader also runs a second query for the top-products leaderboard you'll build later.

***

Here's what each clause does:

* [`FROM` and `SHOW`](https://shopify.dev/docs/api/shopifyql/latest/syntax/from-and-show) read the [`sales`](https://shopify.dev/docs/api/shopifyql/latest/schemas/sales_revenue/sales) dataset for `total_sales` and `orders`.
* [`TIMESERIES day`](https://shopify.dev/docs/api/shopifyql/latest/syntax/timeseries) returns one row per day, which is what the trend chart plots.
* [`WITH TOTALS`](https://shopify.dev/docs/api/shopifyql/latest/syntax/with) gives you this week's total sales as a single figure, and last week's total with [`COMPARE TO previous_period`](https://shopify.dev/docs/api/shopifyql/latest/syntax/compare-to).

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Read the results from the response

The response comes back as a `tableData` object with typed `columns` and `rows`, so your UI reads named values.

#### Read the columns and rows

Read `columns` and `rows` from `tableData`. Each column tells you its `name`, its `dataType`, and a human-readable `displayName` that you can use for headers and labels.

***

* Read columns by `name`. Positional reads break as soon as you reorder the `SHOW` clause.
* A cell can be null when a metric has no value for a row, so render a placeholder for null cells.
* Use `displayName` for headers and legends, so the UI follows the query when the columns change.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

#### Format each value by its data type

Use each column's `dataType` to decide how to format its values. This query returns values in three data types.

***

* `DAY_TIMESTAMP`: A day-granularity ISO 8601 date, like `"2026-01-15"`. The timestamp granularity follows the `TIMESERIES` unit, so grouping by `month` returns a month-granularity timestamp instead. Parse it and format for the user's locale, or use it directly as a chart axis.
* `MONEY`: A decimal string, like `"2547.83"`. Keep it as a string to avoid floating-point rounding, and format it with the store's currency.
* `INTEGER`: A whole number returned as a string, like `"42"`. Parse it to an integer if you need to calculate with it.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

#### Read the total and compute the change

`WITH TOTALS` puts this week's total in `total_sales__totals` and last week's in `comparison_total_sales__previous_period__totals`, repeated on every row. Read both from the first row by `name`, then compute the percent change yourself.

***

* `total_sales__totals` is this week's total. Keep it as a string for display, and parse it to a number only for the change calculation.
* `comparison_total_sales__previous_period__totals` is last week's total, already summed for the whole period. Read it from the first row, the same way you read this week's total.
* Compute the change as `(thisWeek - lastWeek) / abs(lastWeek) * 100`. If last week is zero, then there's nothing to compare against, so skip the change.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Show the headline metric

The total and its change from last week are the headline, so they go at the top of the page. A metric card shows both, above the chart and table.

#### Render the metric card

Render the total in a Polaris web component section with `total_sales__totals` formatted as the store's currency, and show the computed percent change as an up or down badge against last week. If there's no earlier period to compare against, then show the total on its own.

***

The section, heading, and badge are Polaris web components, so they match the Shopify admin without any styling of your own.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Show the trend

The headline total is a single number. To show whether sales rose or fell across the week, plot the per-day series as a line chart under the metric.

#### Render the trend chart

Map the per-day rows to data points and render them with [Polaris Viz](https://github.com/Shopify/polaris-viz), Shopify's React charting library. Use the `DAY_TIMESTAMP` column for the x-axis and `total_sales` for the line.

***

Install `@shopify/polaris-viz`:

## Terminal

```shell
npm install @shopify/polaris-viz
```

Wrap the chart in a `PolarisVizProvider` and import its stylesheet once. The chart is the only React component in the dashboard. The metric card, table, and app shell are Polaris web components, and the chart renders alongside them from the same route.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

#### Render the chart only on the client

Polaris Viz reads `window` when it draws, so it can't render during server-side rendering. Track when the component mounts on the client, and render the chart only after that. The rest of the page still renders on the server.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

#### Add a detail table

`WITH TOTALS` and `COMPARE TO` add the `total_sales__totals` and `comparison_total_sales__previous_period__totals` columns that the metric card reads, so filter out every `__totals` and `comparison_` column and show only the per-day columns in the table. Map the remaining columns to headers using each column's `displayName`, and then map the `rows` to cells. Read each cell by column `name` so that the values line up with their headers.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Rank the top products

The total and trend show how much the store sold. A second query shows what sold, ranking the store's best-selling products.

#### Query and render the leaderboard

The loader already runs this second query from Step 2. Define it and render the results as a ranked list or table below the trend.

***

* [`GROUP BY TOP 10 product_title`](https://shopify.dev/docs/api/shopifyql/latest/syntax/group-by) keeps the 10 products with the highest `net_sales` and folds the rest into a single `Other` row, so the list still sums to the store's total. Add `ONLY` before `TOP` to drop the `Other` row.
* `ORDER BY net_sales DESC` sorts the returned rows from highest to lowest.
* `SINCE -30d` ranks over the last 30 days instead of the 7 the rest of the dashboard covers, because a single week is often too sparse for a stable ranking.

`net_sales` accounts for discounts and returns, and `product_title` comes back on each row, so you render product names without a second lookup.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Handle empty, loading, and error states

A dashboard runs against live stores, so it needs to hold up in states your working query won't trigger during the build. Handle a failed query, a store with no sales yet, and data that's still loading.

#### Show a message for parse errors

When `parseErrors` contains a message, the query failed and returned no `tableData`. The dashboard runs two queries, so combine the `parseErrors` from both and render them in a banner instead of the dashboard.

***

Parse errors flag problems with the query text you wrote, such as an unknown field or a syntax mistake. Shopify returns them in the response rather than throwing GraphQL errors, so check `parseErrors` before you read `tableData`. During development, they're how you catch and fix a malformed query.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

#### Handle empty and loading states

If the store has no sales in the period, then show an empty state instead of a chart of flat zeros. Show a loading state for later navigations that re-run the loader, such as filters you might add.

***

Because `TIMESERIES` fills every day in the range, an empty period comes back as rows of zeros rather than zero rows, so check whether the period total is zero instead of the row count. React Router runs the loader before the first paint, so the first load never flashes empty. For the loading state, read the navigation state so a later navigation that re-runs the query shows a placeholder instead of stale data.

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

### Run the app

You've built every piece of the dashboard. Run the app on your development store to see it work end to end.

#### Start the development server

From your app's directory, start the app with Shopify CLI, then open the preview URL it prints.

## Terminal

```shell
shopify app dev
```

***

On first run, Shopify CLI installs the app on your development store and completes the OAuth flow, so the loader's GraphQL Admin API request is authenticated. Open the app and confirm the dashboard shows this week's total sales, the change from last week, the daily trend, and the top products.

## /shopify.app.toml

```toml
# This file stores configuration for your Shopify app.
# `shopify app config link` populates client_id, application_url, and redirect_urls.
# Learn more: https://shopify.dev/docs/apps/tools/cli/configuration
client_id = ""


[access_scopes]
# read_reports grants access to a store's analytics and reporting data, which
# ShopifyQL queries read through the shopifyqlQuery field. Request it when the
# merchant installs your app.
scopes = "read_reports"


[webhooks]
api_version = "2025-10"


  # Handled by: /app/routes/webhooks.app.uninstalled.jsx
  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks/app/uninstalled"
```

## /app/routes/app.\_index.jsx

```jsx
import {useEffect, useState} from 'react';
import {useLoaderData, useNavigation} from 'react-router';
import {LineChart, PolarisVizProvider} from '@shopify/polaris-viz';
import '@shopify/polaris-viz/build/esm/styles.css';
import {authenticate} from '../shopify.server';


const TOP_PRODUCTS_QUERY = `
  query TopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW net_sales GROUP BY TOP 10 product_title SINCE -30d ORDER BY net_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


const SALES_QUERY = `
  query SalesThisWeek {
    shop {
      currencyCode
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders TIMESERIES day SINCE -7d COMPARE TO previous_period WITH TOTALS ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
```

## Tutorial complete!

You built an embedded app that reports a store's weekly sales. It shows a metric card, a trend chart, and a top-products leaderboard, and handles empty, loading, and error states.

### Next steps

Learn how to handle the errors and rate limits real stores hit, and explore the full ShopifyQL query language.

[Handle errors and budgets\
\
](https://shopify.dev/docs/apps/build/shopifyql/graphql-admin-api/errors-limits-and-performance)

[Handle failed queries, rate limits, and data that isn't real time.](https://shopify.dev/docs/apps/build/shopifyql/graphql-admin-api/errors-limits-and-performance)

[ShopifyQL reference\
\
](https://shopify.dev/docs/api/shopifyql)

[Explore the full query language, including datasets, metrics, dimensions, and clauses.](https://shopify.dev/docs/api/shopifyql)
