Skip to main content

Build a sales dashboard with the GraphQL 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.

You'll learn how to do the following tasks:

  • Write a ShopifyQL query for a store's daily sales this week and last.
  • Read the total from the tableData response and compute the week-over-week change.
  • Rank the store's top products with a second query that uses GROUP BY TOP.
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.

Requirements

Scaffold an app

An embedded app built from the React Router template. An app built with Shopify CLI has authentication already configured, so your app can make authenticated GraphQL Admin API requests.

Request protected customer data access

shopifyqlQuery requires Level 2 access to protected customer data. Approval can take time, so request it early.

Use a supported API version

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

Project

Anchor to Request the access scopeRequest the access scope

shopifyqlQuery requires the read_reports access scope. Request it in your app's configuration so that users grant it during installation.

Anchor to Add the access scopeAdd 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, which covers the name, address, phone, and email fields. Request both.

Anchor to Write the ShopifyQL queryWrite the ShopifyQL 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.

Anchor to Run the query in a loaderRun 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:

Anchor to Read the results from the responseRead the results from the response

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

Anchor to Read the columns and rowsRead 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.

Anchor to Format each value by its data typeFormat 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.

Anchor to Read the total and compute the changeRead 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.

Anchor to Show the headline metricShow 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.

Anchor to Render the metric cardRender 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.

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.

Anchor to Render the trend chartRender the trend chart

Map the per-day rows to data points and render them with 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

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.

Anchor to Render the chart only on the clientRender 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.

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.

Anchor to Rank the top productsRank 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.

Anchor to Query and render the leaderboardQuery 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 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.

Anchor to Handle empty, loading, and error statesHandle 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.

Anchor to Show a message for parse errorsShow 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.

Anchor to Handle empty and loading statesHandle 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.

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

Anchor to Start the development serverStart the development server

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

Terminal

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.

Was this page helpful?